From 67a62e61756ab799cc154b52af252119a07f249b Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 29 Jul 2026 17:16:39 -0700 Subject: [PATCH 01/33] refactor: add replaceable node runtime boundary --- docs/ARCHITECTURE.md | 3 + docs/RUST_NODE_RUNTIME_MIGRATION.md | 40 ++++++ src/OpenClaw.Connection/INodeConnector.cs | 8 +- .../INodeRuntimeClientFactory.cs | 33 +++++ src/OpenClaw.Connection/NodeConnector.cs | 33 +++-- src/OpenClaw.Shared/INodeRuntimeClient.cs | 52 ++++++++ src/OpenClaw.Shared/WindowsNodeClient.cs | 2 +- .../A2UI/Actions/GatewayActionTransport.cs | 4 +- .../Services/NodeService.cs | 24 ++-- .../NodeConnectorTests.cs | 122 ++++++++++++++++-- 10 files changed, 276 insertions(+), 45 deletions(-) create mode 100644 docs/RUST_NODE_RUNTIME_MIGRATION.md create mode 100644 src/OpenClaw.Connection/INodeRuntimeClientFactory.cs create mode 100644 src/OpenClaw.Shared/INodeRuntimeClient.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0d7c352d0..b53251d4b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,6 +66,7 @@ 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 | | Gateway connect envelope | `ConnectEnvelopeBuilder` (planned) | planned | | Gateway request tracking | `PendingRequestRegistry` (planned) | planned | @@ -80,6 +81,7 @@ 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/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 +140,7 @@ 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-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/RUST_NODE_RUNTIME_MIGRATION.md b/docs/RUST_NODE_RUNTIME_MIGRATION.md new file mode 100644 index 000000000..8f19eceab --- /dev/null +++ b/docs/RUST_NODE_RUNTIME_MIGRATION.md @@ -0,0 +1,40 @@ +# 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. + +## Intended follow-up slices + +1. Add an opt-in sidecar adapter that implements `INodeRuntimeClient` over a + versioned, authenticated local IPC protocol. The Rust process owns Gateway + connection, registration, invoke/result/progress/cancellation, reconnect, and + runtime lifecycle. +2. Run the C# and Rust implementations through the same registration and + invocation conformance fixtures. Keep the existing C# runtime as the default + while the Rust path gathers real Gateway proof. +3. 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. +4. 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 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..b5c3a35e2 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; @@ -130,11 +130,10 @@ private async Task ConnectCoreAsync( () => DisconnectIfCurrent(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) @@ -302,7 +301,7 @@ private void DisconnectInternalCore() } private void ThrowIfNotCurrent( - WindowsNodeClient client, + INodeRuntimeClient client, long generation, CancellationToken cancellationToken) { diff --git a/src/OpenClaw.Shared/INodeRuntimeClient.cs b/src/OpenClaw.Shared/INodeRuntimeClient.cs new file mode 100644 index 000000000..87e74aa22 --- /dev/null +++ b/src/OpenClaw.Shared/INodeRuntimeClient.cs @@ -0,0 +1,52 @@ +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); + Task ConnectAsync(); + Task DisconnectAsync(); + Task SendNodeEventAsync(string eventName, JsonObject payload); +} diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 52289cbd0..49d7ad816 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -15,7 +15,7 @@ namespace OpenClaw.Shared; /// Windows Node client - extends gateway connection to act as a node /// Supports both operator (existing) and node (new) roles /// -public class WindowsNodeClient : WebSocketClientBase +public class WindowsNodeClient : WebSocketClientBase, INodeRuntimeClient { private readonly DeviceIdentity _deviceIdentity; 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..772d70afe 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; @@ -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..17b838c5f 100644 --- a/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs +++ b/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs @@ -14,6 +14,81 @@ 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 StubNodeRuntimeClient : INodeRuntimeClient + { + private readonly Dictionary _permissions = []; + + public bool UseV2Signature { 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 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 Task ConnectAsync() + { + PermissionWasSetBeforeConnect = _permissions.GetValueOrDefault("test.permission"); + IsConnected = true; + return Task.CompletedTask; + } + + 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() => IsConnected = false; + } + [Fact] public void InitialState_IsConnected_IsFalse() { @@ -120,6 +195,39 @@ 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 DisconnectAsync_WhenNotConnected_CompletesWithoutError() { @@ -149,15 +257,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 +286,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 +307,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) => { From 5e3c6510043706124c9e3a27975edc518de13f2f Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 29 Jul 2026 21:13:31 -0700 Subject: [PATCH 02/33] refactor: share node capability dispatcher --- docs/ARCHITECTURE.md | 3 + docs/RUST_NODE_RUNTIME_MIGRATION.md | 14 +- .../NodeCapabilityDispatcher.cs | 431 ++++++++++++ src/OpenClaw.Shared/WindowsNodeClient.cs | 649 +++--------------- .../NodeCapabilityDispatcherTests.cs | 141 ++++ 5 files changed, 699 insertions(+), 539 deletions(-) create mode 100644 src/OpenClaw.Shared/NodeCapabilityDispatcher.cs create mode 100644 tests/OpenClaw.Shared.Tests/NodeCapabilityDispatcherTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b53251d4b..db4186d01 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,6 +67,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | 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 | | Gateway connect envelope | `ConnectEnvelopeBuilder` (planned) | planned | | Gateway request tracking | `PendingRequestRegistry` (planned) | planned | @@ -82,6 +83,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | `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` | @@ -141,6 +143,7 @@ leading and trailing pipe. Columns, in order: | 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 | every runtime adapter executes Windows-owned capabilities through one bounded dispatcher with first-registration-wins routing and structured completion | NodeCapabilityDispatcherTests.DispatchAsync_UsesFirstRegisteredCapabilityAndPreservesEventSender | behavioral | when no runtime adapter executes INodeCapability directly | | 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/RUST_NODE_RUNTIME_MIGRATION.md b/docs/RUST_NODE_RUNTIME_MIGRATION.md index 8f19eceab..f6311ae93 100644 --- a/docs/RUST_NODE_RUNTIME_MIGRATION.md +++ b/docs/RUST_NODE_RUNTIME_MIGRATION.md @@ -13,20 +13,26 @@ This change introduces the first executable seam: - `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, and the Rust adapter will reuse it instead of copying + capability policy. ## Intended follow-up slices -1. Add an opt-in sidecar adapter that implements `INodeRuntimeClient` over a +1. Define the versioned, authenticated local IPC messages against the shared + OpenClaw node lifecycle fixtures. +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. -2. Run the C# and Rust implementations through the same registration and +3. Run the C# and Rust implementations through the same registration and invocation conformance fixtures. Keep the existing C# runtime as the default while the Rust path gathers real Gateway proof. -3. Switch the Windows node role to the Rust adapter behind an explicit rollout +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. -4. Remove the duplicated C# Gateway node transport only after parity, rollout, +5. Remove the duplicated C# Gateway node transport only after parity, rollout, and rollback criteria are met. ## Non-goals of this slice 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/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 49d7ad816..b013bf2da 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Frozen; using System.Collections.Generic; -using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -18,11 +16,9 @@ namespace OpenClaw.Shared; 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,7 @@ public class WindowsNodeClient : WebSocketClientBase, INodeRuntimeClient private PairingStatus? _lastEmittedPairingStatus; private readonly string _gatewayToken; private readonly string? _bootstrapToken; - + // Cached serialization/validation — reused on every message instead of allocating per-call private static readonly JsonSerializerOptions s_ignoreNullOptions = new() { @@ -56,17 +52,22 @@ public class WindowsNodeClient : WebSocketClientBase, INodeRuntimeClient 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 +91,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 +137,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 +148,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 +158,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 +199,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 +219,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 +230,7 @@ public void SetPermission(string permission, bool value) { _registration.Permissions[permission] = value; } - + /// /// Disconnect from gateway /// @@ -266,10 +249,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 +260,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 +286,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 +308,7 @@ and not "node.pair.resolved" { _logger.Info($"[NODE] Received event: {eventType}"); } - + switch (eventType) { case "connect.challenge": @@ -448,17 +431,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 +452,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 +516,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 +526,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 +555,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 +572,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 +609,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 +644,7 @@ private string BuildNodeConnectMessage( string? signature = null; var signedAt = ConnectAuthTimestamp.ResolveSignedAt(challengeTimestampMs); var (auth, tokenForSignature) = BuildConnectAuth(); - + if (!string.IsNullOrEmpty(nonce)) { try @@ -784,7 +719,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 +744,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 +761,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 +795,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 +817,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 +829,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 +1109,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 +1120,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 +1147,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 +1207,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 +1232,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 +1276,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 +1286,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 +1301,7 @@ private async Task SendErrorResponseAsync(string requestId, string error) ok = false, error = new { message = error } }; - + await SendRawAsync(JsonSerializer.Serialize(msg)); } @@ -1738,7 +1317,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 +1355,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 +1363,7 @@ private async Task SendPongAsync(string? requestId) ok = true, payload = new { pong = true } }; - + await SendRawAsync(JsonSerializer.Serialize(msg)); } @@ -1795,7 +1374,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 +1391,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 +1405,7 @@ protected override void OnDisconnected() protected override void OnError(Exception ex) { - _activeInvocations.CancelAll(); + _capabilityDispatcher.CancelAll(); _isConnected = false; if (!_pairingBlocked) { @@ -1837,6 +1416,6 @@ protected override void OnError(Exception ex) protected override void OnDisposing() { - _activeInvocations.CancelAll(); + _capabilityDispatcher.CancelAll(); } } 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 }; + } + } +} From a92b5ca3f231667d147c0af72a54ea47e0045c0a Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 30 Jul 2026 16:37:07 -0700 Subject: [PATCH 03/33] fix: harden substitute runtime lifecycle --- docs/ARCHITECTURE.md | 2 +- docs/CONNECTION_ARCHITECTURE.md | 18 ++- docs/RUST_NODE_RUNTIME_MIGRATION.md | 10 +- src/OpenClaw.Connection/NodeConnector.cs | 102 ++++++++----- src/OpenClaw.Shared/INodeRuntimeClient.cs | 6 +- src/OpenClaw.Shared/WindowsNodeClient.cs | 12 ++ .../Services/NodeService.cs | 2 +- .../NodeConnectorTests.cs | 134 +++++++++++++++++- .../AppRefactorContractTests.cs | 12 ++ 9 files changed, 243 insertions(+), 55 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db4186d01..806d17bac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -143,7 +143,7 @@ leading and trailing pipe. Columns, in order: | 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 | every runtime adapter executes Windows-owned capabilities through one bounded dispatcher with first-registration-wins routing and structured completion | NodeCapabilityDispatcherTests.DispatchAsync_UsesFirstRegisteredCapabilityAndPreservesEventSender | behavioral | when no runtime adapter executes INodeCapability directly | +| 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 | | 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 index f6311ae93..1c8362a8d 100644 --- a/docs/RUST_NODE_RUNTIME_MIGRATION.md +++ b/docs/RUST_NODE_RUNTIME_MIGRATION.md @@ -15,8 +15,9 @@ This change introduces the first executable seam: 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, and the Rust adapter will reuse it instead of copying - capability policy. + 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. ## Intended follow-up slices @@ -27,8 +28,9 @@ This change introduces the first executable seam: 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. Keep the existing C# runtime as the default - while the Rust path gathers real Gateway proof. + 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. diff --git a/src/OpenClaw.Connection/NodeConnector.cs b/src/OpenClaw.Connection/NodeConnector.cs index b5c3a35e2..cd7b0413d 100644 --- a/src/OpenClaw.Connection/NodeConnector.cs +++ b/src/OpenClaw.Connection/NodeConnector.cs @@ -110,25 +110,31 @@ 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. Runtime implementations @@ -150,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; } @@ -180,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; } @@ -267,15 +279,6 @@ private void ForwardIfCurrent( } } - private void DisconnectIfCurrent(long generation) - { - lock (_clientLifecycleLock) - { - if (Interlocked.Read(ref _clientGeneration) == generation) - DisconnectInternalCore(); - } - } - private void DisconnectCurrentClient() { DisconnectInternal(); @@ -283,21 +286,42 @@ 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( @@ -315,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 index 87e74aa22..4019375c7 100644 --- a/src/OpenClaw.Shared/INodeRuntimeClient.cs +++ b/src/OpenClaw.Shared/INodeRuntimeClient.cs @@ -46,7 +46,11 @@ public interface INodeRuntimeClient : IDisposable void RegisterCapability(INodeCapability capability); void SetPermission(string permission, bool value); - Task ConnectAsync(); + /// + /// 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/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index b013bf2da..b0fa75203 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -43,6 +43,18 @@ public class WindowsNodeClient : WebSocketClientBase, INodeRuntimeClient 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() { diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 772d70afe..04c002b8b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -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; diff --git a/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs b/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs index 17b838c5f..393646e0a 100644 --- a/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs +++ b/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs @@ -34,6 +34,16 @@ public INodeRuntimeClient Create( } } + 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 = []; @@ -54,6 +64,9 @@ public Func>? 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 { } } @@ -69,11 +82,14 @@ public event EventHandler Disposed { add { } remove { } } public void RegisterCapability(INodeCapability capability) { } public void SetPermission(string permission, bool value) => _permissions[permission] = value; - public Task ConnectAsync() + public async Task ConnectAsync(CancellationToken cancellationToken) { + ConnectWasCalled = true; PermissionWasSetBeforeConnect = _permissions.GetValueOrDefault("test.permission"); + if (ConnectOverride != null) + await ConnectOverride(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); IsConnected = true; - return Task.CompletedTask; } public Task DisconnectAsync() @@ -86,7 +102,11 @@ public Task SendNodeEventAsync( string eventName, System.Text.Json.Nodes.JsonObject payload) => Task.FromResult(true); - public void Dispose() => IsConnected = false; + public void Dispose() + { + WasDisposed = true; + IsConnected = false; + } } [Fact] @@ -228,6 +248,114 @@ await connector.ConnectAsync( 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() { 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() { From 13b4dee3596f4be1e42bccd63af5a5301e81da19 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 30 Jul 2026 20:54:30 -0700 Subject: [PATCH 04/33] feat: prove Rust sidecar adapter contract --- docs/ARCHITECTURE.md | 2 + docs/RUST_NODE_RUNTIME_MIGRATION.md | 37 +- .../AuthenticatedSidecarChannel.cs | 201 +++++ .../RustSidecar/SidecarContracts.cs | 112 +++ .../RustSidecar/SidecarSupervisorHandshake.cs | 187 +++++ .../WindowsSidecarCapabilityAdapter.cs | 550 +++++++++++++ .../RustSidecar/WindowsSidecarSupervisor.cs | 211 +++++ .../OpenClaw.Shared.Tests.csproj | 3 + .../Fixtures/node-sidecar-handshake-v1.json | 52 ++ .../Fixtures/node-sidecar-protocol-v1.json | 15 + .../Fixtures/node-sidecar-runtime-v1.json | 88 +++ .../WindowsSidecarCapabilityAdapterTests.cs | 730 ++++++++++++++++++ 12 files changed, 2186 insertions(+), 2 deletions(-) create mode 100644 src/OpenClaw.Shared/RustSidecar/AuthenticatedSidecarChannel.cs create mode 100644 src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs create mode 100644 src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs create mode 100644 src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs create mode 100644 src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs create mode 100644 tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-handshake-v1.json create mode 100644 tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-protocol-v1.json create mode 100644 tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-runtime-v1.json create mode 100644 tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 806d17bac..b59b91d31 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,6 +68,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | 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 | @@ -144,6 +145,7 @@ leading and trailing pipe. Columns, in order: | 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/RUST_NODE_RUNTIME_MIGRATION.md b/docs/RUST_NODE_RUNTIME_MIGRATION.md index 1c8362a8d..fd4d0f3f2 100644 --- a/docs/RUST_NODE_RUNTIME_MIGRATION.md +++ b/docs/RUST_NODE_RUNTIME_MIGRATION.md @@ -19,10 +19,42 @@ This change introduces the first executable seam: 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. +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 @@ -41,6 +73,7 @@ This change introduces the first executable seam: - 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. 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..9c1b2a056 --- /dev/null +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +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 static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + internal static byte[] Serialize(JsonNode node) => + JsonSerializer.SerializeToUtf8Bytes(node, SerializerOptions); + + internal static JsonElement Parse(ReadOnlySpan json) + { + using var document = JsonDocument.Parse(json.ToArray()); + return document.RootElement.Clone(); + } + + 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)) + throw new SidecarProtocolException($"Sidecar field '{name}' must be an unsigned integer."); + return result; + } +} diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs b/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs new file mode 100644 index 000000000..445401ceb --- /dev/null +++ b/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs @@ -0,0 +1,187 @@ +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 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("Unexpected sidecar handshake message."); + return; + } + + try + { + var message = SidecarJson.Parse(_channel.Open(frame)); + if (SidecarJson.RequiredString(message, "type") != "accept") + throw new SidecarProtocolException("Runtime did not return a sidecar acceptance."); + var remote = ParseOffer(SidecarJson.RequiredObject(message, "offer")); + ValidateOffer(remote, SidecarPeerRole.Runtime); + var claimed = ParseSelection(SidecarJson.RequiredObject(message, "selection")); + var negotiated = Negotiate(_localOffer, remote); + if (claimed != negotiated) + throw new SidecarProtocolException("Runtime sidecar selection does not match local negotiation."); + + _channel.LowerFrameLimit(negotiated.Limits.MaxFrameBytes); + Selection = negotiated; + IsAuthenticated = true; + } + catch + { + _channel.Retire(); + throw; + } + } + + private T Fail(string message) + { + _channel.Retire(); + throw new SidecarProtocolException(message); + } + + private static SidecarProtocolSelection Negotiate( + SidecarProtocolOffer local, + SidecarProtocolOffer remote) + { + if (local.ProtocolMajor != AuthenticatedSidecarChannel.ProtocolMajor || + remote.ProtocolMajor != AuthenticatedSidecarChannel.ProtocolMajor) + { + throw new SidecarProtocolException("Unsupported sidecar protocol major version."); + } + if (local.ProtocolMinor > AuthenticatedSidecarChannel.ProtocolMinor) + throw new SidecarProtocolException("Unsupported local sidecar protocol minor version."); + + return new SidecarProtocolSelection( + AuthenticatedSidecarChannel.ProtocolMajor, + Math.Min(local.ProtocolMinor, remote.ProtocolMinor), + local.FeatureBits & remote.FeatureBits, + new SidecarLimits( + Math.Min(local.Limits.MaxFrameBytes, remote.Limits.MaxFrameBytes), + Math.Min(local.Limits.MaxInFlight, remote.Limits.MaxInFlight), + Math.Min(local.Limits.BootstrapTimeoutMs, remote.Limits.BootstrapTimeoutMs))); + } + + private static SidecarProtocolOffer ValidateOffer( + SidecarProtocolOffer offer, + SidecarPeerRole expectedRole) + { + if (offer.Peer.Role != expectedRole || + string.IsNullOrWhiteSpace(offer.Peer.Name) || + string.IsNullOrWhiteSpace(offer.Peer.Version) || + string.IsNullOrWhiteSpace(offer.Peer.ArtifactIdentity) || + offer.Limits.MaxFrameBytes < 65 || + offer.Limits.MaxInFlight == 0 || + offer.Limits.BootstrapTimeoutMs == 0) + { + throw new SidecarProtocolException("Invalid sidecar protocol offer."); + } + return offer; + } + + private static JsonObject OfferJson(SidecarProtocolOffer offer) => new() + { + ["protocolMajor"] = offer.ProtocolMajor, + ["protocolMinor"] = offer.ProtocolMinor, + ["peer"] = new JsonObject + { + ["role"] = offer.Peer.Role == SidecarPeerRole.Supervisor ? "supervisor" : "runtime", + ["name"] = offer.Peer.Name, + ["version"] = offer.Peer.Version, + ["artifactIdentity"] = offer.Peer.ArtifactIdentity + }, + ["featureBits"] = offer.FeatureBits, + ["limits"] = LimitsJson(offer.Limits) + }; + + private static JsonObject LimitsJson(SidecarLimits limits) => new() + { + ["maxFrameBytes"] = limits.MaxFrameBytes, + ["maxInFlight"] = limits.MaxInFlight, + ["bootstrapTimeoutMs"] = limits.BootstrapTimeoutMs + }; + + private static SidecarProtocolOffer ParseOffer(JsonElement json) + { + var peer = SidecarJson.RequiredObject(json, "peer"); + var role = SidecarJson.RequiredString(peer, "role") switch + { + "supervisor" => SidecarPeerRole.Supervisor, + "runtime" => SidecarPeerRole.Runtime, + _ => throw new SidecarProtocolException("Unknown sidecar peer role.") + }; + return new SidecarProtocolOffer( + checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMajor")), + checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMinor")), + new SidecarPeerIdentity( + role, + SidecarJson.RequiredString(peer, "name"), + SidecarJson.RequiredString(peer, "version"), + SidecarJson.RequiredString(peer, "artifactIdentity")), + SidecarJson.RequiredUInt64(json, "featureBits"), + ParseLimits(SidecarJson.RequiredObject(json, "limits"))); + } + + private static SidecarProtocolSelection ParseSelection(JsonElement json) => new( + checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMajor")), + checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMinor")), + SidecarJson.RequiredUInt64(json, "featureBits"), + ParseLimits(SidecarJson.RequiredObject(json, "limits"))); + + private static SidecarLimits ParseLimits(JsonElement json) => new( + checked((uint)SidecarJson.RequiredUInt64(json, "maxFrameBytes")), + checked((ushort)SidecarJson.RequiredUInt64(json, "maxInFlight")), + checked((uint)SidecarJson.RequiredUInt64(json, "bootstrapTimeoutMs"))); +} diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs new file mode 100644 index 000000000..1bd119d72 --- /dev/null +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -0,0 +1,550 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace OpenClaw.Shared.RustSidecar; + +/// +/// Adapts authenticated Rust runtime messages to the Windows-owned capability dispatcher. +/// Process launch, credential bootstrap, and runtime selection deliberately stay outside this type. +/// +internal sealed class WindowsSidecarCapabilityAdapter +{ + private readonly NodeCapabilityDispatcher _dispatcher; + private readonly string _nodeId; + private readonly HashSet _commands = new(StringComparer.Ordinal); + private readonly object _admissionLock = new(); + private readonly Dictionary _admittedInvocations = new(StringComparer.Ordinal); + private int _maxAdmittedInvocations; + private bool _configurationStarted; + private bool _configured; + private SidecarRuntimeConfiguration? _configuration; + + internal WindowsSidecarCapabilityAdapter(string nodeId, IOpenClawLogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(nodeId); + _nodeId = nodeId; + _dispatcher = new NodeCapabilityDispatcher(this, () => nodeId, logger); + } + + internal IReadOnlyList Capabilities => _dispatcher.Capabilities; + internal bool IsConfigured => _configured; + + internal event EventHandler? InvokeCompleted + { + add => _dispatcher.InvokeCompleted += value; + remove => _dispatcher.InvokeCompleted -= value; + } + + internal void RegisterCapability(INodeCapability capability) + { + if (_configurationStarted) + throw new InvalidOperationException("Sidecar capabilities are immutable after configuration starts."); + _dispatcher.RegisterCapability(capability); + foreach (var command in capability.Commands) + _commands.Add(command); + } + + internal JsonObject BeginConfiguration( + ulong manifestGeneration, + SidecarProtocolSelection selection, + uint maxInputBytes = 1_048_576, + uint maxOutputBytes = 1_048_576, + uint defaultTimeoutMs = 30_000, + uint maxTimeoutMs = 120_000, + uint resultGraceMs = 250) + { + if (_configurationStarted) + throw new InvalidOperationException("Sidecar configuration may be sent only once."); + if (manifestGeneration == 0) + throw new ArgumentOutOfRangeException(nameof(manifestGeneration)); + if (selection.ProtocolMajor != AuthenticatedSidecarChannel.ProtocolMajor || + selection.ProtocolMinor > AuthenticatedSidecarChannel.ProtocolMinor || + selection.Limits.MaxFrameBytes < 65 || + selection.Limits.MaxInFlight == 0 || + selection.Limits.BootstrapTimeoutMs == 0) + { + throw new SidecarProtocolException("Invalid negotiated sidecar selection."); + } + + var capabilities = _dispatcher.Capabilities + .Select(capability => capability.Category) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var commands = _commands.Order(StringComparer.Ordinal).ToArray(); + ValidateNames(capabilities, commands); + + _configuration = new SidecarRuntimeConfiguration( + manifestGeneration, + capabilities, + commands, + Math.Min((ushort)8, selection.Limits.MaxInFlight), + Math.Min(maxInputBytes, selection.Limits.MaxFrameBytes), + Math.Min(maxOutputBytes, selection.Limits.MaxFrameBytes), + defaultTimeoutMs, + maxTimeoutMs, + resultGraceMs); + if (defaultTimeoutMs == 0 || maxTimeoutMs == 0 || + defaultTimeoutMs > maxTimeoutMs || resultGraceMs >= defaultTimeoutMs) + { + throw new ArgumentOutOfRangeException(nameof(defaultTimeoutMs)); + } + _configurationStarted = true; + _maxAdmittedInvocations = _configuration.MaxConcurrency; + return _configuration.ToConfigureMessage(); + } + + internal void ConfirmConfigured(JsonElement message) + { + EnsureMessageShape(message, "configured", "manifest"); + if (!_configurationStarted || _configured || _configuration is null) + throw new SidecarProtocolException("Unexpected sidecar configuration acknowledgement."); + var expected = SidecarJson.Serialize(_configuration.ToManifest()); + var received = JsonSerializer.SerializeToUtf8Bytes( + SidecarJson.RequiredObject(message, "manifest"), + SidecarJson.SerializerOptions); + if (!expected.AsSpan().SequenceEqual(received)) + throw new SidecarProtocolException("Runtime acknowledged a different sidecar manifest."); + _configured = true; + } + + internal async Task HandleRuntimeMessageAsync( + JsonElement message, + CancellationToken connectionCancellation) + { + if (!_configured) + throw new SidecarProtocolException("Runtime traffic arrived before sidecar configuration completed."); + var type = SidecarJson.RequiredString(message, "type"); + return type switch + { + "admission-request" => HandleAdmission(message), + "invoke" => await HandleInvocationAsync(message, connectionCancellation), + "cancel" => HandleCancellation(message), + "status" => HandleStatus(message), + _ => throw new SidecarProtocolException($"Unexpected configured sidecar message '{type}'.") + }; + } + + internal void CancelAll() + { + lock (_admissionLock) + _admittedInvocations.Clear(); + _dispatcher.CancelAll(); + } + + private JsonObject HandleAdmission(JsonElement message) + { + EnsureMessageShape(message, "admission-request", "invocation"); + var invocation = ParseInvocation(SidecarJson.RequiredObject(message, "invocation")); + JsonObject decision; + if (invocation.NodeId != _nodeId) + { + decision = Denial("WRONG_NODE", "invocation targets another Windows node"); + } + else if (!_commands.Contains(invocation.Command)) + { + decision = Denial( + "COMMAND_NOT_ADVERTISED", + "command is not present in the authenticated Windows manifest"); + } + else if (!TryAdmit(invocation)) + { + decision = Denial( + "ADMISSION_SATURATED", + "invocation id is duplicated or the authenticated admission bound is full"); + } + else + { + decision = new JsonObject { ["outcome"] = "allow" }; + } + return new JsonObject + { + ["type"] = "admission-decision", + ["invocationId"] = invocation.Id, + ["decision"] = decision + }; + } + + private async Task HandleInvocationAsync( + JsonElement message, + CancellationToken connectionCancellation) + { + EnsureMessageShape(message, "invoke", "invocation"); + var invocation = ParseInvocation(SidecarJson.RequiredObject(message, "invocation")); + var activation = TryActivateAdmission(invocation); + if (activation == AdmissionActivation.Missing) + return ResultFailure(invocation.Id, "ADMISSION_REQUIRED", "invocation was not admitted by the Windows host"); + if (activation == AdmissionActivation.Mismatch) + return ResultFailure(invocation.Id, "ADMISSION_MISMATCH", "invocation changed after Windows admission"); + if (invocation.NodeId != _nodeId || !_commands.Contains(invocation.Command)) + return ResultFailure(invocation.Id, "COMMAND_NOT_ADVERTISED", "command is not present in the authenticated Windows manifest"); + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var request = new NodeInvokeRequest + { + Id = invocation.Id, + Command = invocation.Command, + Args = invocation.Parameters, + SessionKey = invocation.SessionKey + }; + + try + { + Task dispatch; + lock (_admissionLock) + { + var admission = _admittedInvocations.GetValueOrDefault(invocation.Id); + if (admission is null || admission.CancellationRequested) + return ResultFailure(invocation.Id, "WINDOWS_CAPABILITY", "cancelled"); + + // DispatchAsync registers the invocation synchronously before scheduling its + // worker. Holding the admission lock closes the activation-to-registration + // window: cancellation either wins above, or observes a registered invocation. + dispatch = _dispatcher.DispatchAsync( + request, + async response => + { + completion.TrySetResult(response.Ok + ? await BuildSuccessResultAsync(invocation.Id, response.Payload) + : BuildCapabilityFailure(invocation.Id, response.Error)); + }, + error => + { + completion.TrySetResult(ResultFailure(invocation.Id, "WINDOWS_CAPABILITY", error)); + return Task.CompletedTask; + }, + connectionCancellation); + } + await dispatch; + return await completion.Task.WaitAsync(connectionCancellation); + } + catch (OperationCanceledException) + { + _dispatcher.TryCancel(invocation.Id); + throw; + } + finally + { + ReleaseAdmission(invocation.Id); + } + } + + private JsonObject? HandleCancellation(JsonElement message) + { + EnsureMessageShape(message, "cancel", "invocationId"); + var invocationId = SidecarJson.RequiredString(message, "invocationId"); + var active = false; + lock (_admissionLock) + { + if (_admittedInvocations.TryGetValue(invocationId, out var admission)) + { + if (admission.Active) + { + admission.CancellationRequested = true; + active = true; + } + else + { + _admittedInvocations.Remove(invocationId); + } + } + } + if (active) + _dispatcher.TryCancel(invocationId); + return null; + } + + private JsonObject? HandleStatus(JsonElement message) + { + EnsureMessageShape(message, "status", "status"); + var status = SidecarJson.RequiredObject(message, "status"); + EnsureProperties( + status, + "state", "manifestGeneration", "runtimeVersion", "attempt", "reason"); + _ = SidecarJson.RequiredString(status, "state") switch + { + "configured" or "connecting" or "ready" or "backing-off" or + "paused" or "draining" or "stopped" => true, + _ => throw new SidecarProtocolException("Unknown sidecar runtime state.") + }; + if (SidecarJson.RequiredUInt64(status, "manifestGeneration") != _configuration!.ManifestGeneration) + throw new SidecarProtocolException("Sidecar status belongs to another manifest generation."); + _ = SidecarJson.RequiredString(status, "runtimeVersion"); + _ = SidecarJson.RequiredUInt64(status, "attempt"); + var reason = status.GetProperty("reason"); + if (reason.ValueKind == JsonValueKind.String) + { + _ = reason.GetString() switch + { + "transport" or "gateway" or "request-timeout" or "event-lagged" or + "activation" or "delivery-saturated" or "result-task" or + "runtime-ended" or "shutdown" or "pairing" or "authentication" or + "protocol" or "configuration" or "identity" => true, + _ => throw new SidecarProtocolException("Unknown sidecar runtime reason.") + }; + } + else if (reason.ValueKind != JsonValueKind.Null) + { + throw new SidecarProtocolException("Sidecar runtime reason must be a string or null."); + } + return null; + } + + private bool TryAdmit(SidecarInvocation invocation) + { + lock (_admissionLock) + { + if (_admittedInvocations.Count >= _maxAdmittedInvocations || + _admittedInvocations.ContainsKey(invocation.Id)) + { + return false; + } + _admittedInvocations.Add(invocation.Id, new Admission(invocation.CanonicalJson)); + return true; + } + } + + private AdmissionActivation TryActivateAdmission(SidecarInvocation invocation) + { + lock (_admissionLock) + { + if (!_admittedInvocations.TryGetValue(invocation.Id, out var admission) || admission.Active) + return AdmissionActivation.Missing; + if (!admission.CanonicalJson.AsSpan().SequenceEqual(invocation.CanonicalJson)) + { + _admittedInvocations.Remove(invocation.Id); + return AdmissionActivation.Mismatch; + } + admission.Active = true; + return AdmissionActivation.Activated; + } + } + + private void ReleaseAdmission(string invocationId) + { + lock (_admissionLock) + _admittedInvocations.Remove(invocationId); + } + + private static JsonObject Denial(string code, string message) => new() + { + ["outcome"] = "deny", + ["code"] = code, + ["message"] = message + }; + + private async Task BuildSuccessResultAsync(string invocationId, object? payload) + { + try + { + using var output = new BoundedWriteStream(_configuration!.MaxOutputBytes); + await JsonSerializer.SerializeAsync( + output, + payload, + payload?.GetType() ?? typeof(object), + SidecarJson.SerializerOptions); + var payloadNode = JsonNode.Parse(output.WrittenMemory.Span); + return new JsonObject + { + ["type"] = "result", + ["invocationId"] = invocationId, + ["result"] = new JsonObject + { + ["outcome"] = "success", + ["payload"] = payloadNode + } + }; + } + catch (SidecarOutputLimitException) + { + return OutputTooLargeFailure(invocationId); + } + catch (Exception) + { + return ResultFailure( + invocationId, + "RESULT_SERIALIZATION", + "Windows capability result could not be serialized"); + } + } + + internal static JsonObject OutputTooLargeFailure(string invocationId) => + ResultFailure(invocationId, "OUTPUT_TOO_LARGE", "Windows capability result exceeds the negotiated output bound"); + + private JsonObject BuildCapabilityFailure(string invocationId, string? error) + { + var message = error ?? "Windows capability failed"; + return Encoding.UTF8.GetByteCount(message) > _configuration!.MaxOutputBytes + ? OutputTooLargeFailure(invocationId) + : ResultFailure(invocationId, "WINDOWS_CAPABILITY", message); + } + + private static JsonObject ResultFailure(string invocationId, string code, string message) => new() + { + ["type"] = "result", + ["invocationId"] = invocationId, + ["result"] = new JsonObject + { + ["outcome"] = "failure", + ["code"] = code, + ["message"] = message + } + }; + + private static SidecarInvocation ParseInvocation(JsonElement invocation) + { + EnsureProperties( + invocation, + "id", "nodeId", "command", "params", "timeoutMs", "idempotencyKey", "sessionKey"); + if (!invocation.TryGetProperty("params", out var parameters)) + throw new SidecarProtocolException("Sidecar invocation is missing params."); + _ = OptionalUInt64(invocation, "timeoutMs"); + _ = OptionalString(invocation, "idempotencyKey"); + var id = SidecarJson.RequiredString(invocation, "id"); + var nodeId = SidecarJson.RequiredString(invocation, "nodeId"); + var command = SidecarJson.RequiredString(invocation, "command"); + if (id.Length == 0 || nodeId.Length == 0 || command.Length == 0) + throw new SidecarProtocolException("Sidecar invocation identifiers must not be empty."); + return new SidecarInvocation( + id, + nodeId, + command, + parameters.Clone(), + OptionalString(invocation, "sessionKey"), + JsonSerializer.SerializeToUtf8Bytes(invocation)); + } + + private static string? OptionalString(JsonElement parent, string name) + { + var value = parent.GetProperty(name); + return value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.String when value.GetString() is { Length: > 0 } text => text, + JsonValueKind.String => throw new SidecarProtocolException( + $"Sidecar field '{name}' must not be empty when present."), + _ => throw new SidecarProtocolException($"Sidecar field '{name}' must be a string or null.") + }; + } + + private static ulong? OptionalUInt64(JsonElement parent, string name) + { + var value = parent.GetProperty(name); + if (value.ValueKind == JsonValueKind.Null) + return null; + if (value.TryGetUInt64(out var result)) + return result; + throw new SidecarProtocolException($"Sidecar field '{name}' must be an unsigned integer or null."); + } + + private static void ValidateNames(IReadOnlyList capabilities, IReadOnlyList commands) + { + foreach (var name in capabilities.Concat(commands)) + { + if (string.IsNullOrEmpty(name) || name.Length > 128 || + name.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('.' or '_' or '-'))) + { + throw new SidecarProtocolException($"Invalid sidecar manifest name '{name}'."); + } + } + if (commands.Any(command => command == "system" || command.StartsWith("system.", StringComparison.Ordinal))) + { + throw new SidecarProtocolException( + "The current OpenClaw sidecar bridge does not yet admit the system command namespace."); + } + } + + private static void EnsureMessageShape(JsonElement message, string type, params string[] fields) + { + EnsureProperties(message, ["type", .. fields]); + if (SidecarJson.RequiredString(message, "type") != type) + throw new SidecarProtocolException($"Expected sidecar message '{type}'."); + } + + private static void EnsureProperties(JsonElement value, params string[] expected) + { + if (value.ValueKind != JsonValueKind.Object) + throw new SidecarProtocolException("Sidecar message 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 message is missing a required field."); + } + + private sealed record SidecarInvocation( + string Id, + string NodeId, + string Command, + JsonElement Parameters, + string? SessionKey, + byte[] CanonicalJson); + + private sealed class Admission(byte[] canonicalJson) + { + internal byte[] CanonicalJson { get; } = canonicalJson; + internal bool Active { get; set; } + internal bool CancellationRequested { get; set; } + } + + private enum AdmissionActivation + { + Activated, + Missing, + Mismatch + } + + private sealed class BoundedWriteStream(uint maximumBytes) : Stream + { + private readonly MemoryStream _inner = new(); + private readonly long _maximumBytes = maximumBytes; + + internal ReadOnlyMemory WrittenMemory => _inner.GetBuffer().AsMemory(0, checked((int)_inner.Length)); + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => throw new NotSupportedException(); } + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override void Write(byte[] buffer, int offset, int count) + { + EnsureCapacity(count); + _inner.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + EnsureCapacity(buffer.Length); + _inner.Write(buffer); + } + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Write(buffer.Span); + return ValueTask.CompletedTask; + } + + private void EnsureCapacity(int additionalBytes) + { + if (additionalBytes < 0 || _inner.Length > _maximumBytes - additionalBytes) + throw new SidecarOutputLimitException(); + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + private sealed class SidecarOutputLimitException : Exception; +} diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs new file mode 100644 index 000000000..544f458f3 --- /dev/null +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs @@ -0,0 +1,211 @@ +using System.Text.Json.Nodes; +using System.Threading.Channels; + +namespace OpenClaw.Shared.RustSidecar; + +/// +/// Drives one authenticated sidecar session from handshake through Windows capability dispatch. +/// The product process owner supplies the already-protected session key and transports returned frames. +/// +internal sealed class WindowsSidecarSupervisor : IDisposable +{ + private readonly AuthenticatedSidecarChannel _channel; + private readonly SidecarSupervisorHandshake _handshake; + private readonly WindowsSidecarCapabilityAdapter _adapter; + private readonly ulong _manifestGeneration; + private readonly object _channelLock = new(); + private readonly Channel _outbound = Channel.CreateBounded(new BoundedChannelOptions(8) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false + }); + private readonly CancellationTokenSource _retirement = new(); + private bool _configurationSent; + private bool _disposed; + + internal WindowsSidecarSupervisor( + string sessionId, + ulong generation, + ReadOnlySpan sessionKey, + uint bootstrapFrameLimit, + SidecarProtocolOffer localOffer, + WindowsSidecarCapabilityAdapter adapter, + ulong manifestGeneration) + { + _channel = new AuthenticatedSidecarChannel( + SidecarPeerRole.Supervisor, + sessionId, + generation, + sessionKey, + bootstrapFrameLimit); + _handshake = new SidecarSupervisorHandshake(_channel, localOffer); + _adapter = adapter; + _manifestGeneration = manifestGeneration; + } + + internal bool IsAuthenticated => _handshake.IsAuthenticated; + internal bool IsConfigured => _adapter.IsConfigured; + internal bool IsRetired => _channel.IsRetired; + + internal byte[] Start() + { + ThrowIfDisposed(); + lock (_channelLock) + return _handshake.Start(); + } + + internal byte[] CompleteHandshake(ReadOnlySpan runtimeAcceptance) + { + ThrowIfDisposed(); + if (_configurationSent) + return Fail("Sidecar configuration has already been sent."); + try + { + lock (_channelLock) + _handshake.Accept(runtimeAcceptance); + var configure = _adapter.BeginConfiguration( + _manifestGeneration, + _handshake.Selection!); + _configurationSent = true; + lock (_channelLock) + return _channel.Seal(SidecarJson.Serialize(configure)); + } + catch + { + Retire(); + throw; + } + } + + internal async Task ReceiveAsync( + ReadOnlyMemory runtimeFrame, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (!_configurationSent) + Fail("Runtime traffic arrived before the sidecar handshake completed."); + try + { + byte[] payload; + lock (_channelLock) + payload = _channel.Open(runtimeFrame.Span); + var message = SidecarJson.Parse(payload); + if (!_adapter.IsConfigured) + { + _adapter.ConfirmConfigured(message); + return; + } + + if (SidecarJson.RequiredString(message, "type") == "invoke") + { + _ = ProcessInvocationAsync(message, cancellationToken); + return; + } + + var response = await _adapter.HandleRuntimeMessageAsync(message, cancellationToken); + if (response is not null) + QueueResponse(response); + } + catch + { + Retire(); + throw; + } + } + + internal async ValueTask ReadOutboundAsync(CancellationToken cancellationToken) + { + var frame = await _outbound.Reader.ReadAsync(cancellationToken); + if (_retirement.IsCancellationRequested) + { + System.Security.Cryptography.CryptographicOperations.ZeroMemory(frame); + throw new SidecarProtocolException("Sidecar session is retired."); + } + return frame; + } + + internal void Retire() => Retire(null); + + private void Retire(Exception? error) + { + if (!_retirement.IsCancellationRequested) + _retirement.Cancel(); + _adapter.CancelAll(); + lock (_channelLock) + { + _channel.Retire(); + _outbound.Writer.TryComplete(error); + while (_outbound.Reader.TryRead(out var frame)) + System.Security.Cryptography.CryptographicOperations.ZeroMemory(frame); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Retire(); + _retirement.Dispose(); + } + + private async Task ProcessInvocationAsync( + System.Text.Json.JsonElement message, + CancellationToken connectionCancellation) + { + try + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + connectionCancellation, + _retirement.Token); + var response = await _adapter.HandleRuntimeMessageAsync(message, linked.Token); + if (response is not null) + QueueResponse(response); + } + catch (OperationCanceledException) when ( + connectionCancellation.IsCancellationRequested || _retirement.IsCancellationRequested) + { + Retire(); + } + catch (Exception error) + { + Retire(error); + } + } + + private void QueueResponse(JsonObject response) + { + lock (_channelLock) + { + var payload = SidecarJson.Serialize(response); + if (payload.Length > _channel.MaxPayloadBytes) + { + if (response["type"]?.GetValue() != "result" || + response["invocationId"]?.GetValue() is not { Length: > 0 } invocationId) + { + throw new SidecarProtocolException("Sidecar response exceeds the authenticated payload bound."); + } + payload = SidecarJson.Serialize( + WindowsSidecarCapabilityAdapter.OutputTooLargeFailure(invocationId)); + if (payload.Length > _channel.MaxPayloadBytes) + throw new SidecarProtocolException("Sidecar output failure exceeds the authenticated payload bound."); + } + var frame = _channel.Seal(payload); + if (!_outbound.Writer.TryWrite(frame)) + throw new SidecarProtocolException("Sidecar outbound response queue is saturated."); + } + } + + private T Fail(string message) + { + Retire(); + throw new SidecarProtocolException(message); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(WindowsSidecarSupervisor)); + } +} 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..c0c5eaaad --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-runtime-v1.json @@ -0,0 +1,88 @@ +{ + "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 + } + } + ], + "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}}" + ] +} diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs new file mode 100644 index 000000000..3dc41888a --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -0,0 +1,730 @@ +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 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 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 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 Supervisor_ReplacesOversizedResultEnvelopeWithoutRetiringSession() + { + 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("OUTPUT_TOO_LARGE", result.GetProperty("result").GetProperty("code").GetString()); + Assert.False(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_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 void Adapter_RecordsCurrentRustSystemNamespaceGapInsteadOfSelectingIt() + { + var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger()); + adapter.RegisterCapability(new TestCapability( + "system", + "system.run", + (_, _) => 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 maxOutputBytes = 1_048_576) + { + var configure = adapter.BeginConfiguration( + 1, + new SidecarProtocolSelection(1, 0, 0, new SidecarLimits(4096, maxInFlight, 1000)), + maxOutputBytes: maxOutputBytes); + 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) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + 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 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) { } + } +} From 3b0eeba10816a6e782e5881fdaaaf839dbf03a8d Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 01:03:02 -0700 Subject: [PATCH 05/33] fix: align Windows sidecar contract --- .../RustSidecar/SidecarContracts.cs | 18 +++ .../RustSidecar/SidecarSupervisorHandshake.cs | 40 +++++-- .../WindowsSidecarCapabilityAdapter.cs | 46 +++++-- .../RustSidecar/WindowsSidecarSupervisor.cs | 27 ++++- .../Fixtures/node-sidecar-runtime-v1.json | 13 +- .../WindowsSidecarCapabilityAdapterTests.cs | 113 +++++++++++++++++- 6 files changed, 235 insertions(+), 22 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 9c1b2a056..ecea8ffb4 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -74,6 +74,8 @@ internal JsonObject ToConfigureMessage() internal static class SidecarJson { + internal const ulong MaxPortableInteger = 9_007_199_254_740_991; + internal static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -109,4 +111,20 @@ internal static ulong RequiredUInt64(JsonElement parent, string name) throw new SidecarProtocolException($"Sidecar field '{name}' must be an 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."); + } } diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs b/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs index 445401ceb..4afef9a91 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs @@ -34,6 +34,7 @@ internal SidecarSupervisorHandshake( internal bool IsAuthenticated { get; private set; } internal SidecarProtocolSelection? Selection { get; private set; } + internal string? RuntimeVersion { get; private set; } internal byte[] Start() { @@ -66,6 +67,7 @@ internal void Accept(ReadOnlySpan frame) try { var message = SidecarJson.Parse(_channel.Open(frame)); + SidecarJson.EnsureObjectShape(message, "type", "offer", "selection"); if (SidecarJson.RequiredString(message, "type") != "accept") throw new SidecarProtocolException("Runtime did not return a sidecar acceptance."); var remote = ParseOffer(SidecarJson.RequiredObject(message, "offer")); @@ -77,6 +79,7 @@ internal void Accept(ReadOnlySpan frame) _channel.LowerFrameLimit(negotiated.Limits.MaxFrameBytes); Selection = negotiated; + RuntimeVersion = remote.Peer.Version; IsAuthenticated = true; } catch @@ -124,7 +127,8 @@ private static SidecarProtocolOffer ValidateOffer( string.IsNullOrWhiteSpace(offer.Peer.ArtifactIdentity) || offer.Limits.MaxFrameBytes < 65 || offer.Limits.MaxInFlight == 0 || - offer.Limits.BootstrapTimeoutMs == 0) + offer.Limits.BootstrapTimeoutMs == 0 || + offer.FeatureBits > SidecarJson.MaxPortableInteger) { throw new SidecarProtocolException("Invalid sidecar protocol offer."); } @@ -155,7 +159,11 @@ private static SidecarProtocolOffer ValidateOffer( private static SidecarProtocolOffer ParseOffer(JsonElement json) { + SidecarJson.EnsureObjectShape( + json, + "protocolMajor", "protocolMinor", "peer", "featureBits", "limits"); var peer = SidecarJson.RequiredObject(json, "peer"); + SidecarJson.EnsureObjectShape(peer, "role", "name", "version", "artifactIdentity"); var role = SidecarJson.RequiredString(peer, "role") switch { "supervisor" => SidecarPeerRole.Supervisor, @@ -174,14 +182,26 @@ private static SidecarProtocolOffer ParseOffer(JsonElement json) ParseLimits(SidecarJson.RequiredObject(json, "limits"))); } - private static SidecarProtocolSelection ParseSelection(JsonElement json) => new( - checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMajor")), - checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMinor")), - SidecarJson.RequiredUInt64(json, "featureBits"), - ParseLimits(SidecarJson.RequiredObject(json, "limits"))); + private static SidecarProtocolSelection ParseSelection(JsonElement json) + { + SidecarJson.EnsureObjectShape( + json, + "protocolMajor", "protocolMinor", "featureBits", "limits"); + return new SidecarProtocolSelection( + checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMajor")), + checked((ushort)SidecarJson.RequiredUInt64(json, "protocolMinor")), + SidecarJson.RequiredUInt64(json, "featureBits"), + ParseLimits(SidecarJson.RequiredObject(json, "limits"))); + } - private static SidecarLimits ParseLimits(JsonElement json) => new( - checked((uint)SidecarJson.RequiredUInt64(json, "maxFrameBytes")), - checked((ushort)SidecarJson.RequiredUInt64(json, "maxInFlight")), - checked((uint)SidecarJson.RequiredUInt64(json, "bootstrapTimeoutMs"))); + private static SidecarLimits ParseLimits(JsonElement json) + { + SidecarJson.EnsureObjectShape( + json, + "maxFrameBytes", "maxInFlight", "bootstrapTimeoutMs"); + return new SidecarLimits( + checked((uint)SidecarJson.RequiredUInt64(json, "maxFrameBytes")), + checked((ushort)SidecarJson.RequiredUInt64(json, "maxInFlight")), + checked((uint)SidecarJson.RequiredUInt64(json, "bootstrapTimeoutMs"))); + } } diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 1bd119d72..b1fecfa8b 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -56,10 +56,11 @@ internal JsonObject BeginConfiguration( { if (_configurationStarted) throw new InvalidOperationException("Sidecar configuration may be sent only once."); - if (manifestGeneration == 0) + if (manifestGeneration == 0 || manifestGeneration > SidecarJson.MaxPortableInteger) throw new ArgumentOutOfRangeException(nameof(manifestGeneration)); if (selection.ProtocolMajor != AuthenticatedSidecarChannel.ProtocolMajor || selection.ProtocolMinor > AuthenticatedSidecarChannel.ProtocolMinor || + selection.FeatureBits > SidecarJson.MaxPortableInteger || selection.Limits.MaxFrameBytes < 65 || selection.Limits.MaxInFlight == 0 || selection.Limits.BootstrapTimeoutMs == 0) @@ -74,22 +75,30 @@ internal JsonObject BeginConfiguration( .ToArray(); var commands = _commands.Order(StringComparer.Ordinal).ToArray(); ValidateNames(capabilities, commands); + if (maxInputBytes == 0) + throw new ArgumentOutOfRangeException(nameof(maxInputBytes)); + if (maxOutputBytes == 0) + throw new ArgumentOutOfRangeException(nameof(maxOutputBytes)); + var boundedInputBytes = Math.Min(maxInputBytes, selection.Limits.MaxFrameBytes); + var boundedOutputBytes = Math.Min(maxOutputBytes, selection.Limits.MaxFrameBytes); + if (boundedOutputBytes < MinimumBridgeFailureBytes()) + throw new ArgumentOutOfRangeException(nameof(maxOutputBytes)); + if (defaultTimeoutMs == 0 || maxTimeoutMs == 0 || + defaultTimeoutMs > maxTimeoutMs || resultGraceMs >= defaultTimeoutMs) + { + throw new ArgumentOutOfRangeException(nameof(defaultTimeoutMs)); + } _configuration = new SidecarRuntimeConfiguration( manifestGeneration, capabilities, commands, Math.Min((ushort)8, selection.Limits.MaxInFlight), - Math.Min(maxInputBytes, selection.Limits.MaxFrameBytes), - Math.Min(maxOutputBytes, selection.Limits.MaxFrameBytes), + boundedInputBytes, + boundedOutputBytes, defaultTimeoutMs, maxTimeoutMs, resultGraceMs); - if (defaultTimeoutMs == 0 || maxTimeoutMs == 0 || - defaultTimeoutMs > maxTimeoutMs || resultGraceMs >= defaultTimeoutMs) - { - throw new ArgumentOutOfRangeException(nameof(defaultTimeoutMs)); - } _configurationStarted = true; _maxAdmittedInvocations = _configuration.MaxConcurrency; return _configuration.ToConfigureMessage(); @@ -372,6 +381,12 @@ await JsonSerializer.SerializeAsync( internal static JsonObject OutputTooLargeFailure(string invocationId) => ResultFailure(invocationId, "OUTPUT_TOO_LARGE", "Windows capability result exceeds the negotiated output bound"); + internal static JsonObject MessageTooLargeFailure(string invocationId) => + ResultFailure( + invocationId, + "SIDECAR_MESSAGE_TOO_LARGE", + "complete sidecar message exceeds the authenticated payload limit"); + private JsonObject BuildCapabilityFailure(string invocationId, string? error) { var message = error ?? "Windows capability failed"; @@ -392,6 +407,21 @@ private JsonObject BuildCapabilityFailure(string invocationId, string? error) } }; + private static uint MinimumBridgeFailureBytes() + { + var failures = new[] + { + ("SIDECAR_MESSAGE_TOO_LARGE", "complete sidecar message exceeds the authenticated payload limit"), + ("SIDECAR_NON_PORTABLE_JSON", "sidecar message contains an integer outside the exact JSON range"), + ("SIDECAR_CHANNEL_RETIRED", "authenticated sidecar channel is no longer live") + }; + return checked((uint)failures.Max(failure => SidecarJson.Serialize(new JsonObject + { + ["code"] = failure.Item1, + ["message"] = failure.Item2 + }).Length)); + } + private static SidecarInvocation ParseInvocation(JsonElement invocation) { EnsureProperties( diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs index 544f458f3..76a1d39a7 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs @@ -64,6 +64,10 @@ internal byte[] CompleteHandshake(ReadOnlySpan runtimeAcceptance) { lock (_channelLock) _handshake.Accept(runtimeAcceptance); + ValidateStatusBudget( + _handshake.RuntimeVersion!, + _manifestGeneration, + _channel.MaxPayloadBytes); var configure = _adapter.BeginConfiguration( _manifestGeneration, _handshake.Selection!); @@ -187,7 +191,7 @@ private void QueueResponse(JsonObject response) throw new SidecarProtocolException("Sidecar response exceeds the authenticated payload bound."); } payload = SidecarJson.Serialize( - WindowsSidecarCapabilityAdapter.OutputTooLargeFailure(invocationId)); + WindowsSidecarCapabilityAdapter.MessageTooLargeFailure(invocationId)); if (payload.Length > _channel.MaxPayloadBytes) throw new SidecarProtocolException("Sidecar output failure exceeds the authenticated payload bound."); } @@ -203,6 +207,27 @@ private T Fail(string message) throw new SidecarProtocolException(message); } + private static void ValidateStatusBudget( + string runtimeVersion, + ulong manifestGeneration, + int maxPayloadBytes) + { + var worstCaseStatus = new JsonObject + { + ["type"] = "status", + ["status"] = new JsonObject + { + ["state"] = "backing-off", + ["manifestGeneration"] = manifestGeneration, + ["runtimeVersion"] = runtimeVersion, + ["attempt"] = SidecarJson.MaxPortableInteger, + ["reason"] = "delivery-saturated" + } + }; + if (SidecarJson.Serialize(worstCaseStatus).Length > maxPayloadBytes) + throw new SidecarProtocolException("Sidecar runtime status exceeds the authenticated payload bound."); + } + private void ThrowIfDisposed() { if (_disposed) 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 index c0c5eaaad..33c143576 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-runtime-v1.json +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-runtime-v1.json @@ -73,6 +73,16 @@ "attempt": 1, "reason": null } + }, + { + "type": "status", + "status": { + "state": "ready", + "manifestGeneration": 9007199254740991, + "runtimeVersion": "1.0.0", + "attempt": 9007199254740991, + "reason": null + } } ], "canonicalJson": [ @@ -83,6 +93,7 @@ "{\"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\":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 index 3dc41888a..7a86a7efc 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -111,6 +111,57 @@ public void Handshake_RejectsRuntimeChannelBeforeSending() 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() { @@ -392,7 +443,7 @@ public async Task Adapter_BoundsCapabilityErrorBeforeBuildingResultEnvelope() } [Fact] - public async Task Supervisor_ReplacesOversizedResultEnvelopeWithoutRetiringSession() + public async Task Supervisor_UsesSidecarErrorForOversizedResultEnvelopeWithoutRetiringSession() { var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger()); adapter.RegisterCapability(new TestCapability( @@ -427,10 +478,68 @@ await supervisor.ReceiveAsync( var result = SidecarJson.Parse(runtime.Open( await supervisor.ReadOutboundAsync(CancellationToken.None))); - Assert.Equal("OUTPUT_TOO_LARGE", result.GetProperty("result").GetProperty("code").GetString()); + 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 async Task Supervisor_DiscardsBufferedFramesOnTerminalAuthenticationFailure() { From ec82b6632084e92a6e362ab05572cf9a36d1bc0a Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 01:14:17 -0700 Subject: [PATCH 06/33] fix: enforce portable sidecar results --- .../RustSidecar/SidecarContracts.cs | 19 +++++ .../WindowsSidecarCapabilityAdapter.cs | 11 ++- .../RustSidecar/WindowsSidecarSupervisor.cs | 25 ++++++ .../WindowsSidecarCapabilityAdapterTests.cs | 83 +++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index ecea8ffb4..2f0da1eae 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -127,4 +127,23 @@ internal static void EnsureObjectShape(JsonElement value, params string[] expect 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); + 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; + } } diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index b1fecfa8b..cc7cbf1e7 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -353,7 +353,10 @@ await JsonSerializer.SerializeAsync( payload, payload?.GetType() ?? typeof(object), SidecarJson.SerializerOptions); - var payloadNode = JsonNode.Parse(output.WrittenMemory.Span); + var payloadJson = output.WrittenMemory.Span; + if (!SidecarJson.IsPortableJson(SidecarJson.Parse(payloadJson))) + return NonPortableJsonFailure(invocationId); + var payloadNode = JsonNode.Parse(payloadJson); return new JsonObject { ["type"] = "result", @@ -387,6 +390,12 @@ internal static JsonObject MessageTooLargeFailure(string invocationId) => "SIDECAR_MESSAGE_TOO_LARGE", "complete sidecar message exceeds the authenticated payload limit"); + internal static JsonObject NonPortableJsonFailure(string invocationId) => + ResultFailure( + invocationId, + "SIDECAR_NON_PORTABLE_JSON", + "sidecar message contains an integer outside the exact JSON range"); + private JsonObject BuildCapabilityFailure(string invocationId, string? error) { var message = error ?? "Windows capability failed"; diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs index 76a1d39a7..f9199f635 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs @@ -68,6 +68,7 @@ internal byte[] CompleteHandshake(ReadOnlySpan runtimeAcceptance) _handshake.RuntimeVersion!, _manifestGeneration, _channel.MaxPayloadBytes); + ValidateResultFailureBudget(string.Empty, _channel.MaxPayloadBytes); var configure = _adapter.BeginConfiguration( _manifestGeneration, _handshake.Selection!); @@ -103,10 +104,14 @@ internal async Task ReceiveAsync( if (SidecarJson.RequiredString(message, "type") == "invoke") { + ValidateInvocationFailureBudget(message, _channel.MaxPayloadBytes); _ = ProcessInvocationAsync(message, cancellationToken); return; } + if (SidecarJson.RequiredString(message, "type") == "admission-request") + ValidateInvocationFailureBudget(message, _channel.MaxPayloadBytes); + var response = await _adapter.HandleRuntimeMessageAsync(message, cancellationToken); if (response is not null) QueueResponse(response); @@ -228,6 +233,26 @@ private static void ValidateStatusBudget( throw new SidecarProtocolException("Sidecar runtime status exceeds the authenticated payload bound."); } + private static void ValidateInvocationFailureBudget( + System.Text.Json.JsonElement message, + int maxPayloadBytes) + { + var invocation = SidecarJson.RequiredObject(message, "invocation"); + ValidateResultFailureBudget( + SidecarJson.RequiredString(invocation, "id"), + maxPayloadBytes); + } + + private static void ValidateResultFailureBudget(string invocationId, int maxPayloadBytes) + { + if (SidecarJson.Serialize( + WindowsSidecarCapabilityAdapter.MessageTooLargeFailure(invocationId)).Length > maxPayloadBytes) + { + throw new SidecarProtocolException( + "Sidecar invocation failure exceeds the authenticated payload bound."); + } + } + private void ThrowIfDisposed() { if (_disposed) diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 7a86a7efc..6a7054e98 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -442,6 +442,31 @@ public async Task Adapter_BoundsCapabilityErrorBeforeBuildingResultEnvelope() Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.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 = ulong.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 Supervisor_UsesSidecarErrorForOversizedResultEnvelopeWithoutRetiringSession() { @@ -540,6 +565,64 @@ public void Supervisor_RejectsConfigurationWhenWorstCaseStatusCannotFit() 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 async Task Supervisor_DiscardsBufferedFramesOnTerminalAuthenticationFailure() { From 735a696d994a43dc785e14a4958897613ec04b58 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 01:28:32 -0700 Subject: [PATCH 07/33] fix: enforce sidecar input bounds --- .../RustSidecar/SidecarContracts.cs | 12 ++++-- .../WindowsSidecarCapabilityAdapter.cs | 27 +++++++++++- .../WindowsSidecarCapabilityAdapterTests.cs | 43 ++++++++++++++++++- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 2f0da1eae..988a557a9 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -107,8 +107,13 @@ internal static string RequiredString(JsonElement parent, string name) internal static ulong RequiredUInt64(JsonElement parent, string name) { - if (!parent.TryGetProperty(name, out var value) || !value.TryGetUInt64(out var result)) - throw new SidecarProtocolException($"Sidecar field '{name}' must be an unsigned integer."); + 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; } @@ -139,7 +144,8 @@ internal static void EnsureObjectShape(JsonElement value, params string[] expect private static bool IsPortableNumber(JsonElement value) { if (value.TryGetInt64(out var signed)) - return signed >= -checked((long)MaxPortableInteger); + 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)) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index cc7cbf1e7..a7a8dd32f 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -151,6 +151,16 @@ private JsonObject HandleAdmission(JsonElement message) { decision = Denial("WRONG_NODE", "invocation targets another Windows node"); } + else if (!SidecarJson.IsPortableJson(invocation.Parameters)) + { + decision = Denial( + "SIDECAR_NON_PORTABLE_JSON", + "sidecar message contains an integer outside the exact JSON range"); + } + else if (!InputWithinLimit(invocation.Parameters)) + { + decision = Denial("INPUT_TOO_LARGE", "command parameters exceed the runtime limit"); + } else if (!_commands.Contains(invocation.Command)) { decision = Denial( @@ -181,6 +191,15 @@ private async Task HandleInvocationAsync( { EnsureMessageShape(message, "invoke", "invocation"); var invocation = ParseInvocation(SidecarJson.RequiredObject(message, "invocation")); + if (!SidecarJson.IsPortableJson(invocation.Parameters)) + return NonPortableJsonFailure(invocation.Id); + if (!InputWithinLimit(invocation.Parameters)) + { + return ResultFailure( + invocation.Id, + "INPUT_TOO_LARGE", + "command parameters exceed the runtime limit"); + } var activation = TryActivateAdmission(invocation); if (activation == AdmissionActivation.Missing) return ResultFailure(invocation.Id, "ADMISSION_REQUIRED", "invocation was not admitted by the Windows host"); @@ -431,6 +450,9 @@ private static uint MinimumBridgeFailureBytes() }).Length)); } + private bool InputWithinLimit(JsonElement parameters) => + JsonSerializer.SerializeToUtf8Bytes(parameters).Length <= _configuration!.MaxInputBytes; + private static SidecarInvocation ParseInvocation(JsonElement invocation) { EnsureProperties( @@ -472,9 +494,10 @@ JsonValueKind.String when value.GetString() is { Length: > 0 } text => text, var value = parent.GetProperty(name); if (value.ValueKind == JsonValueKind.Null) return null; - if (value.TryGetUInt64(out var result)) + if (value.TryGetUInt64(out var result) && result <= SidecarJson.MaxPortableInteger) return result; - throw new SidecarProtocolException($"Sidecar field '{name}' must be an unsigned integer or null."); + throw new SidecarProtocolException( + $"Sidecar field '{name}' must be a portable unsigned integer or null."); } private static void ValidateNames(IReadOnlyList capabilities, IReadOnlyList commands) diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 6a7054e98..07488964b 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -452,7 +452,7 @@ public async Task Adapter_RejectsNonPortableIntegerCapabilityOutput() (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, - Payload = new { unsafeValue = ulong.MaxValue } + Payload = new { unsafeValue = long.MaxValue } }))); Configure(adapter, maxOutputBytes: 1024); var invocation = ParseJson(""" @@ -467,6 +467,32 @@ public async Task Adapter_RejectsNonPortableIntegerCapabilityOutput() 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() { @@ -692,6 +718,19 @@ public async Task Adapter_RequiresAnUnchangedAdmissionBeforeDispatch() Assert.Equal(0, executions); } + [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_HoldsAdmissionSlotAndIdUntilInvocationIsTerminal() { @@ -755,11 +794,13 @@ public void Adapter_RecordsCurrentRustSystemNamespaceGapInsteadOfSelectingIt() private static void Configure( WindowsSidecarCapabilityAdapter adapter, ushort maxInFlight = 8, + uint maxInputBytes = 1_048_576, uint maxOutputBytes = 1_048_576) { var configure = adapter.BeginConfiguration( 1, new SidecarProtocolSelection(1, 0, 0, new SidecarLimits(4096, maxInFlight, 1000)), + maxInputBytes: maxInputBytes, maxOutputBytes: maxOutputBytes); var configuration = configure["configuration"]!.AsObject(); var manifest = new JsonObject From 6a920262c3fad384b5c6b95142cd9e40f9252117 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 01:38:56 -0700 Subject: [PATCH 08/33] fix: release rejected sidecar admissions --- .../WindowsSidecarCapabilityAdapter.cs | 17 ++++++++---- .../WindowsSidecarCapabilityAdapterTests.cs | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index a7a8dd32f..c0296926f 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -191,22 +191,29 @@ private async Task HandleInvocationAsync( { EnsureMessageShape(message, "invoke", "invocation"); var invocation = ParseInvocation(SidecarJson.RequiredObject(message, "invocation")); + var activation = TryActivateAdmission(invocation); + if (activation == AdmissionActivation.Missing) + return ResultFailure(invocation.Id, "ADMISSION_REQUIRED", "invocation was not admitted by the Windows host"); + if (activation == AdmissionActivation.Mismatch) + return ResultFailure(invocation.Id, "ADMISSION_MISMATCH", "invocation changed after Windows admission"); if (!SidecarJson.IsPortableJson(invocation.Parameters)) + { + ReleaseAdmission(invocation.Id); return NonPortableJsonFailure(invocation.Id); + } if (!InputWithinLimit(invocation.Parameters)) { + ReleaseAdmission(invocation.Id); return ResultFailure( invocation.Id, "INPUT_TOO_LARGE", "command parameters exceed the runtime limit"); } - var activation = TryActivateAdmission(invocation); - if (activation == AdmissionActivation.Missing) - return ResultFailure(invocation.Id, "ADMISSION_REQUIRED", "invocation was not admitted by the Windows host"); - if (activation == AdmissionActivation.Mismatch) - return ResultFailure(invocation.Id, "ADMISSION_MISMATCH", "invocation changed after Windows admission"); if (invocation.NodeId != _nodeId || !_commands.Contains(invocation.Command)) + { + ReleaseAdmission(invocation.Id); return ResultFailure(invocation.Id, "COMMAND_NOT_ADVERTISED", "command is not present in the authenticated Windows manifest"); + } var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var request = new NodeInvokeRequest diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 07488964b..63b8ed7e0 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -731,6 +731,33 @@ 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("ADMISSION_MISMATCH", mismatch!["result"]!["code"]!.GetValue()); + Assert.Equal("allow", nextDecision!["decision"]!["outcome"]!.GetValue()); + } + [Fact] public async Task Adapter_HoldsAdmissionSlotAndIdUntilInvocationIsTerminal() { From 9d2f76224844333021074be9efd41a5c541eafcc Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 01:48:05 -0700 Subject: [PATCH 09/33] fix: bound all sidecar responses --- .../WindowsSidecarCapabilityAdapter.cs | 39 ++++++++--- .../RustSidecar/WindowsSidecarSupervisor.cs | 17 +++++ .../WindowsSidecarCapabilityAdapterTests.cs | 70 +++++++++++++++++++ 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index c0296926f..7222c988d 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -177,12 +176,7 @@ private JsonObject HandleAdmission(JsonElement message) { decision = new JsonObject { ["outcome"] = "allow" }; } - return new JsonObject - { - ["type"] = "admission-decision", - ["invocationId"] = invocation.Id, - ["decision"] = decision - }; + return AdmissionDecision(invocation.Id, decision); } private async Task HandleInvocationAsync( @@ -369,6 +363,34 @@ private void ReleaseAdmission(string invocationId) ["message"] = message }; + private static JsonObject AdmissionDecision(string invocationId, JsonObject decision) => new() + { + ["type"] = "admission-decision", + ["invocationId"] = invocationId, + ["decision"] = decision + }; + + internal static int MaximumAdmissionDecisionBytes(string invocationId) + { + var decisions = new[] + { + new JsonObject { ["outcome"] = "allow" }, + Denial("WRONG_NODE", "invocation targets another Windows node"), + Denial( + "SIDECAR_NON_PORTABLE_JSON", + "sidecar message contains an integer outside the exact JSON range"), + Denial("INPUT_TOO_LARGE", "command parameters exceed the runtime limit"), + Denial( + "COMMAND_NOT_ADVERTISED", + "command is not present in the authenticated Windows manifest"), + Denial( + "ADMISSION_SATURATED", + "invocation id is duplicated or the authenticated admission bound is full") + }; + return decisions.Max(decision => + SidecarJson.Serialize(AdmissionDecision(invocationId, decision)).Length); + } + private async Task BuildSuccessResultAsync(string invocationId, object? payload) { try @@ -425,7 +447,8 @@ internal static JsonObject NonPortableJsonFailure(string invocationId) => private JsonObject BuildCapabilityFailure(string invocationId, string? error) { var message = error ?? "Windows capability failed"; - return Encoding.UTF8.GetByteCount(message) > _configuration!.MaxOutputBytes + return JsonSerializer.SerializeToUtf8Bytes(message, SidecarJson.SerializerOptions).Length > + _configuration!.MaxOutputBytes ? OutputTooLargeFailure(invocationId) : ResultFailure(invocationId, "WINDOWS_CAPABILITY", message); } diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs index f9199f635..604140631 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs @@ -69,6 +69,7 @@ internal byte[] CompleteHandshake(ReadOnlySpan runtimeAcceptance) _manifestGeneration, _channel.MaxPayloadBytes); ValidateResultFailureBudget(string.Empty, _channel.MaxPayloadBytes); + ValidateAdmissionDecisionBudget(string.Empty, _channel.MaxPayloadBytes); var configure = _adapter.BeginConfiguration( _manifestGeneration, _handshake.Selection!); @@ -110,7 +111,14 @@ internal async Task ReceiveAsync( } if (SidecarJson.RequiredString(message, "type") == "admission-request") + { ValidateInvocationFailureBudget(message, _channel.MaxPayloadBytes); + ValidateAdmissionDecisionBudget( + SidecarJson.RequiredString( + SidecarJson.RequiredObject(message, "invocation"), + "id"), + _channel.MaxPayloadBytes); + } var response = await _adapter.HandleRuntimeMessageAsync(message, cancellationToken); if (response is not null) @@ -253,6 +261,15 @@ private static void ValidateResultFailureBudget(string invocationId, int maxPayl } } + private static void ValidateAdmissionDecisionBudget(string invocationId, int maxPayloadBytes) + { + if (WindowsSidecarCapabilityAdapter.MaximumAdmissionDecisionBytes(invocationId) > maxPayloadBytes) + { + throw new SidecarProtocolException( + "Sidecar admission decision exceeds the authenticated payload bound."); + } + } + private void ThrowIfDisposed() { if (_disposed) diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 63b8ed7e0..e4da9353c 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -442,6 +442,29 @@ public async Task Adapter_BoundsCapabilityErrorBeforeBuildingResultEnvelope() 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_RejectsNonPortableIntegerCapabilityOutput() { @@ -649,6 +672,53 @@ public void Supervisor_RejectsConfigurationWhenStableResultFailureCannotFit() 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() { From cf6357b9dc0e85069d3a496319ab4bf02dcddd3f Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 02:13:14 -0700 Subject: [PATCH 10/33] fix: align Windows sidecar value semantics --- .../RustSidecar/SidecarContracts.cs | 58 ++++++++++- .../WindowsSidecarCapabilityAdapter.cs | 82 ++++++++++++---- .../WindowsSidecarCapabilityAdapterTests.cs | 96 +++++++++++++++++++ 3 files changed, 217 insertions(+), 19 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 988a557a9..080a6efb4 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -75,11 +75,13 @@ internal JsonObject ToConfigureMessage() internal static class SidecarJson { internal const ulong MaxPortableInteger = 9_007_199_254_740_991; + internal const int MaxDepth = 128; internal static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false + WriteIndented = false, + MaxDepth = MaxDepth }; internal static byte[] Serialize(JsonNode node) => @@ -87,10 +89,62 @@ internal static byte[] Serialize(JsonNode node) => internal static JsonElement Parse(ReadOnlySpan json) { - using var document = JsonDocument.Parse(json.ToArray()); + 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 + }; + } + + 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) + { + if (left.TryGetInt64(out var leftInteger)) + return right.TryGetInt64(out var rightInteger) && leftInteger == rightInteger; + return !right.TryGetInt64(out _) && left.GetDouble().Equals(right.GetDouble()); + } + + 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(); + } + internal static JsonElement RequiredObject(JsonElement parent, string name) { if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Object) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 7222c988d..9cffa3345 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -12,6 +12,7 @@ internal sealed class WindowsSidecarCapabilityAdapter private readonly NodeCapabilityDispatcher _dispatcher; private readonly string _nodeId; private readonly HashSet _commands = new(StringComparer.Ordinal); + private readonly HashSet _dispatcherCommandIdentities = new(StringComparer.OrdinalIgnoreCase); private readonly object _admissionLock = new(); private readonly Dictionary _admittedInvocations = new(StringComparer.Ordinal); private int _maxAdmittedInvocations; @@ -39,9 +40,21 @@ internal void RegisterCapability(INodeCapability capability) { if (_configurationStarted) throw new InvalidOperationException("Sidecar capabilities are immutable after configuration starts."); + if (_dispatcher.Capabilities.Contains(capability)) + return; + var commands = capability.Commands.ToArray(); + if (commands.Distinct(StringComparer.OrdinalIgnoreCase).Count() != commands.Length || + commands.Any(_dispatcherCommandIdentities.Contains)) + { + throw new SidecarProtocolException( + "Sidecar capability commands collide in the Windows dispatcher."); + } _dispatcher.RegisterCapability(capability); - foreach (var command in capability.Commands) + foreach (var command in commands) + { _commands.Add(command); + _dispatcherCommandIdentities.Add(command); + } } internal JsonObject BeginConfiguration( @@ -108,12 +121,19 @@ internal void ConfirmConfigured(JsonElement message) EnsureMessageShape(message, "configured", "manifest"); if (!_configurationStarted || _configured || _configuration is null) throw new SidecarProtocolException("Unexpected sidecar configuration acknowledgement."); - var expected = SidecarJson.Serialize(_configuration.ToManifest()); - var received = JsonSerializer.SerializeToUtf8Bytes( - SidecarJson.RequiredObject(message, "manifest"), - SidecarJson.SerializerOptions); - if (!expected.AsSpan().SequenceEqual(received)) + var manifest = SidecarJson.RequiredObject(message, "manifest"); + EnsureProperties(manifest, "manifestGeneration", "capabilities", "commands"); + if (SidecarJson.RequiredUInt64(manifest, "manifestGeneration") != + _configuration.ManifestGeneration || + !ReadStringArray(manifest, "capabilities").SequenceEqual( + _configuration.Capabilities, + StringComparer.Ordinal) || + !ReadStringArray(manifest, "commands").SequenceEqual( + _configuration.Commands, + StringComparer.Ordinal)) + { throw new SidecarProtocolException("Runtime acknowledged a different sidecar manifest."); + } _configured = true; } @@ -236,7 +256,7 @@ private async Task HandleInvocationAsync( { completion.TrySetResult(response.Ok ? await BuildSuccessResultAsync(invocation.Id, response.Payload) - : BuildCapabilityFailure(invocation.Id, response.Error)); + : await BuildCapabilityFailureAsync(invocation.Id, response.Error)); }, error => { @@ -340,7 +360,7 @@ private AdmissionActivation TryActivateAdmission(SidecarInvocation invocation) { if (!_admittedInvocations.TryGetValue(invocation.Id, out var admission) || admission.Active) return AdmissionActivation.Missing; - if (!admission.CanonicalJson.AsSpan().SequenceEqual(invocation.CanonicalJson)) + if (!SidecarJson.ValueEquals(admission.CanonicalJson, invocation.CanonicalJson)) { _admittedInvocations.Remove(invocation.Id); return AdmissionActivation.Mismatch; @@ -444,13 +464,26 @@ internal static JsonObject NonPortableJsonFailure(string invocationId) => "SIDECAR_NON_PORTABLE_JSON", "sidecar message contains an integer outside the exact JSON range"); - private JsonObject BuildCapabilityFailure(string invocationId, string? error) + private async Task BuildCapabilityFailureAsync(string invocationId, string? error) { var message = error ?? "Windows capability failed"; - return JsonSerializer.SerializeToUtf8Bytes(message, SidecarJson.SerializerOptions).Length > - _configuration!.MaxOutputBytes - ? OutputTooLargeFailure(invocationId) - : ResultFailure(invocationId, "WINDOWS_CAPABILITY", message); + try + { + using var output = new BoundedWriteStream(_configuration!.MaxOutputBytes); + await JsonSerializer.SerializeAsync( + output, + new JsonObject + { + ["code"] = "WINDOWS_CAPABILITY", + ["message"] = message + }, + SidecarJson.SerializerOptions); + return ResultFailure(invocationId, "WINDOWS_CAPABILITY", message); + } + catch (SidecarOutputLimitException) + { + return OutputTooLargeFailure(invocationId); + } } private static JsonObject ResultFailure(string invocationId, string code, string message) => new() @@ -503,7 +536,7 @@ private static SidecarInvocation ParseInvocation(JsonElement invocation) command, parameters.Clone(), OptionalString(invocation, "sessionKey"), - JsonSerializer.SerializeToUtf8Bytes(invocation)); + invocation.Clone()); } private static string? OptionalString(JsonElement parent, string name) @@ -547,6 +580,21 @@ private static void ValidateNames(IReadOnlyList capabilities, IReadOnlyL } } + private static IReadOnlyList ReadStringArray(JsonElement parent, string name) + { + var value = parent.GetProperty(name); + if (value.ValueKind != JsonValueKind.Array) + throw new SidecarProtocolException($"Sidecar field '{name}' must be an array."); + var values = new List(); + foreach (var item in value.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + throw new SidecarProtocolException($"Sidecar field '{name}' must contain strings."); + values.Add(item.GetString()!); + } + return values; + } + private static void EnsureMessageShape(JsonElement message, string type, params string[] fields) { EnsureProperties(message, ["type", .. fields]); @@ -576,11 +624,11 @@ private sealed record SidecarInvocation( string Command, JsonElement Parameters, string? SessionKey, - byte[] CanonicalJson); + JsonElement CanonicalJson); - private sealed class Admission(byte[] canonicalJson) + private sealed class Admission(JsonElement canonicalJson) { - internal byte[] CanonicalJson { get; } = canonicalJson; + internal JsonElement CanonicalJson { get; } = canonicalJson; internal bool Active { get; set; } internal bool CancellationRequested { get; set; } } diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index e4da9353c..0299c95ac 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -174,6 +174,57 @@ public void RuntimeCorpus_RoundTripsEveryCanonicalRustMessageExactly() } } + [Fact] + public void SidecarJson_UsesRustCompatibleDepthLimit() + { + var supported = Encoding.UTF8.GetBytes(new string('[', 80) + "0" + new string(']', 80)); + 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 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() { @@ -465,6 +516,29 @@ public async Task Adapter_CountsJsonEscapingAgainstCapabilityErrorLimit() 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_RejectsNonPortableIntegerCapabilityOutput() { @@ -788,6 +862,28 @@ public async Task Adapter_RequiresAnUnchangedAdmissionBeforeDispatch() Assert.Equal(0, 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_RejectsNonPortableInvocationTimeout() { From fc5627c9acab6e14d9ca6e03465734f0e6d3ce40 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 02:32:15 -0700 Subject: [PATCH 11/33] fix: match serde JSON limits --- .../RustSidecar/SidecarContracts.cs | 36 ++++++- .../WindowsSidecarCapabilityAdapter.cs | 7 +- .../WindowsSidecarCapabilityAdapterTests.cs | 95 +++++++++++++++++-- 3 files changed, 125 insertions(+), 13 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 080a6efb4..0c5058dd5 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -1,3 +1,4 @@ +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; @@ -75,13 +76,15 @@ internal JsonObject ToConfigureMessage() internal static class SidecarJson { internal const ulong MaxPortableInteger = 9_007_199_254_740_991; - internal const int MaxDepth = 128; + // serde_json starts with 128 remaining levels and rejects the 128th container. + internal const int MaxDepth = 127; internal static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, - MaxDepth = MaxDepth + MaxDepth = MaxDepth, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; internal static byte[] Serialize(JsonNode node) => @@ -133,9 +136,32 @@ private static Dictionary ToPropertyMap(JsonElement value) private static bool NumberEquals(JsonElement left, JsonElement right) { - if (left.TryGetInt64(out var leftInteger)) - return right.TryGetInt64(out var rightInteger) && leftInteger == rightInteger; - return !right.TryGetInt64(out _) && left.GetDouble().Equals(right.GetDouble()); + var leftKind = GetNumberKind(left); + if (leftKind != GetNumberKind(right)) + return false; + return leftKind switch + { + JsonNumberKind.PositiveInteger => + left.GetUInt64() == right.GetUInt64(), + JsonNumberKind.NegativeInteger => + left.GetInt64() == right.GetInt64(), + _ => left.GetDouble().Equals(right.GetDouble()) + }; + } + + 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 diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 9cffa3345..6d6b62ee3 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -424,7 +424,9 @@ await JsonSerializer.SerializeAsync( var payloadJson = output.WrittenMemory.Span; if (!SidecarJson.IsPortableJson(SidecarJson.Parse(payloadJson))) return NonPortableJsonFailure(invocationId); - var payloadNode = JsonNode.Parse(payloadJson); + var payloadNode = JsonNode.Parse( + payloadJson, + documentOptions: new JsonDocumentOptions { MaxDepth = SidecarJson.MaxDepth }); return new JsonObject { ["type"] = "result", @@ -514,7 +516,8 @@ private static uint MinimumBridgeFailureBytes() } private bool InputWithinLimit(JsonElement parameters) => - JsonSerializer.SerializeToUtf8Bytes(parameters).Length <= _configuration!.MaxInputBytes; + JsonSerializer.SerializeToUtf8Bytes(parameters, SidecarJson.SerializerOptions).Length <= + _configuration!.MaxInputBytes; private static SidecarInvocation ParseInvocation(JsonElement invocation) { diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 0299c95ac..f7417829f 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -177,7 +177,8 @@ public void RuntimeCorpus_RoundTripsEveryCanonicalRustMessageExactly() [Fact] public void SidecarJson_UsesRustCompatibleDepthLimit() { - var supported = Encoding.UTF8.GetBytes(new string('[', 80) + "0" + new string(']', 80)); + 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)); @@ -539,6 +540,50 @@ public async Task Adapter_CountsFullCapabilityFailurePayloadAgainstOutputLimit() 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_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_RejectsNonPortableIntegerCapabilityOutput() { @@ -884,6 +929,47 @@ public async Task Adapter_TreatsReorderedInvocationObjectsAsUnchanged() Assert.Equal("success", result!["result"]!["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() { @@ -1100,11 +1186,8 @@ await supervisor.ReceiveAsync( private static JsonElement ParseCanonical(JsonElement canonical) => ParseJson(canonical.GetString()!); - private static JsonElement ParseJson(string json) - { - using var document = JsonDocument.Parse(json); - return document.RootElement.Clone(); - } + 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))); From 9285fcda9a1a0c6b369d4b30e27cbc6a2996271a Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 02:44:35 -0700 Subject: [PATCH 12/33] fix: encode sidecar JSON like serde --- src/OpenClaw.Shared/OpenClaw.Shared.csproj | 1 + .../RustSidecar/SidecarContracts.cs | 82 ++++++++++++++++++- .../RustSidecar/WindowsSidecarSupervisor.cs | 14 +++- .../WindowsSidecarCapabilityAdapterTests.cs | 49 +++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) 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/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 0c5058dd5..462efbfb2 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -84,7 +84,7 @@ internal static class SidecarJson PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, MaxDepth = MaxDepth, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + Encoder = SerdeJsonEncoder.Instance }; internal static byte[] Serialize(JsonNode node) => @@ -171,6 +171,86 @@ private sealed class JsonElementValueComparer : IEqualityComparer public int GetHashCode(JsonElement obj) => throw new NotSupportedException(); } + 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) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs index 604140631..aa716b39f 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarSupervisor.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Channels; @@ -195,7 +196,18 @@ private void QueueResponse(JsonObject response) { lock (_channelLock) { - var payload = SidecarJson.Serialize(response); + byte[] payload; + try + { + payload = SidecarJson.Serialize(response); + } + catch (JsonException) when ( + response["type"]?.GetValue() == "result" && + response["invocationId"]?.GetValue() is { Length: > 0 } invocationId) + { + payload = SidecarJson.Serialize( + WindowsSidecarCapabilityAdapter.MessageTooLargeFailure(invocationId)); + } if (payload.Length > _channel.MaxPayloadBytes) { if (response["type"]?.GetValue() != "result" || diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index f7417829f..09b55ec36 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -190,6 +190,18 @@ public void SidecarJson_UsesRustCompatibleDepthLimit() 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() { @@ -347,6 +359,43 @@ await supervisor.ReceiveAsync( 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() { From 45b9dbaa326f72b09b3b24a9261f1f4f5b9529ee Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 02:53:14 -0700 Subject: [PATCH 13/33] fix: enforce sidecar handler deadlines --- .../WindowsSidecarCapabilityAdapter.cs | 35 +++++++++++++- .../WindowsSidecarCapabilityAdapterTests.cs | 46 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 6d6b62ee3..2cc94af30 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -228,6 +228,12 @@ private async Task HandleInvocationAsync( ReleaseAdmission(invocation.Id); return ResultFailure(invocation.Id, "COMMAND_NOT_ADVERTISED", "command is not present in the authenticated Windows manifest"); } + var executionTimeout = ResolveTimeout(invocation.TimeoutMs); + if (executionTimeout == TimeSpan.Zero) + { + ReleaseAdmission(invocation.Id); + return ResultFailure(invocation.Id, "HANDLER_TIMEOUT", "command handler exceeded its deadline"); + } var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var request = new NodeInvokeRequest @@ -266,7 +272,17 @@ private async Task HandleInvocationAsync( connectionCancellation); } await dispatch; - return await completion.Task.WaitAsync(connectionCancellation); + if (executionTimeout is null) + return await completion.Task.WaitAsync(connectionCancellation); + try + { + return await completion.Task.WaitAsync(executionTimeout.Value, connectionCancellation); + } + catch (TimeoutException) + { + _dispatcher.TryCancel(invocation.Id); + return ResultFailure(invocation.Id, "HANDLER_TIMEOUT", "command handler exceeded its deadline"); + } } catch (OperationCanceledException) { @@ -519,6 +535,19 @@ private bool InputWithinLimit(JsonElement parameters) => JsonSerializer.SerializeToUtf8Bytes(parameters, SidecarJson.SerializerOptions).Length <= _configuration!.MaxInputBytes; + private TimeSpan? ResolveTimeout(ulong? requestedTimeoutMs) + { + if (requestedTimeoutMs == 0) + return null; + if (requestedTimeoutMs is null) + return TimeSpan.FromMilliseconds(_configuration!.DefaultTimeoutMs); + var bounded = Math.Min(requestedTimeoutMs.Value, _configuration!.MaxTimeoutMs); + var effective = bounded > _configuration.ResultGraceMs + ? bounded - _configuration.ResultGraceMs + : 0; + return TimeSpan.FromMilliseconds(effective); + } + private static SidecarInvocation ParseInvocation(JsonElement invocation) { EnsureProperties( @@ -526,7 +555,7 @@ private static SidecarInvocation ParseInvocation(JsonElement invocation) "id", "nodeId", "command", "params", "timeoutMs", "idempotencyKey", "sessionKey"); if (!invocation.TryGetProperty("params", out var parameters)) throw new SidecarProtocolException("Sidecar invocation is missing params."); - _ = OptionalUInt64(invocation, "timeoutMs"); + var timeoutMs = OptionalUInt64(invocation, "timeoutMs"); _ = OptionalString(invocation, "idempotencyKey"); var id = SidecarJson.RequiredString(invocation, "id"); var nodeId = SidecarJson.RequiredString(invocation, "nodeId"); @@ -538,6 +567,7 @@ private static SidecarInvocation ParseInvocation(JsonElement invocation) nodeId, command, parameters.Clone(), + timeoutMs, OptionalString(invocation, "sessionKey"), invocation.Clone()); } @@ -626,6 +656,7 @@ private sealed record SidecarInvocation( string NodeId, string Command, JsonElement Parameters, + ulong? TimeoutMs, string? SessionKey, JsonElement CanonicalJson); diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 09b55ec36..f71b88f8f 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -1102,6 +1102,42 @@ await adapter.HandleRuntimeMessageAsync( 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 void Adapter_RecordsCurrentRustSystemNamespaceGapInsteadOfSelectingIt() { @@ -1123,13 +1159,19 @@ private static void Configure( WindowsSidecarCapabilityAdapter adapter, ushort maxInFlight = 8, uint maxInputBytes = 1_048_576, - uint maxOutputBytes = 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); + maxOutputBytes: maxOutputBytes, + defaultTimeoutMs: defaultTimeoutMs, + maxTimeoutMs: maxTimeoutMs, + resultGraceMs: resultGraceMs); var configuration = configure["configuration"]!.AsObject(); var manifest = new JsonObject { From c34ae4f5c0bd0e19c4f95ee78e9db6e95dd2c1a6 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 03:07:06 -0700 Subject: [PATCH 14/33] fix: normalize sidecar invocation parameters --- .../RustSidecar/SidecarContracts.cs | 38 +++++++++++- .../WindowsSidecarCapabilityAdapter.cs | 39 +++++++----- .../WindowsSidecarCapabilityAdapterTests.cs | 60 ++++++++++++++++++- 3 files changed, 118 insertions(+), 19 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 462efbfb2..4ed8dd765 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -116,6 +116,32 @@ internal static bool ValueEquals(JsonElement left, JsonElement right) }; } + 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; + default: + return JsonNode.Parse( + value.GetRawText(), + documentOptions: new JsonDocumentOptions { MaxDepth = MaxDepth }); + } + } + private static bool ObjectEquals(JsonElement left, JsonElement right) { var leftProperties = ToPropertyMap(left); @@ -142,10 +168,16 @@ private static bool NumberEquals(JsonElement left, JsonElement right) return leftKind switch { JsonNumberKind.PositiveInteger => - left.GetUInt64() == right.GetUInt64(), + left.TryGetUInt64(out var leftUnsigned) && + right.TryGetUInt64(out var rightUnsigned) && + leftUnsigned == rightUnsigned, JsonNumberKind.NegativeInteger => - left.GetInt64() == right.GetInt64(), - _ => left.GetDouble().Equals(right.GetDouble()) + 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) }; } diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 2cc94af30..a9c184ec9 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -205,11 +205,6 @@ private async Task HandleInvocationAsync( { EnsureMessageShape(message, "invoke", "invocation"); var invocation = ParseInvocation(SidecarJson.RequiredObject(message, "invocation")); - var activation = TryActivateAdmission(invocation); - if (activation == AdmissionActivation.Missing) - return ResultFailure(invocation.Id, "ADMISSION_REQUIRED", "invocation was not admitted by the Windows host"); - if (activation == AdmissionActivation.Mismatch) - return ResultFailure(invocation.Id, "ADMISSION_MISMATCH", "invocation changed after Windows admission"); if (!SidecarJson.IsPortableJson(invocation.Parameters)) { ReleaseAdmission(invocation.Id); @@ -223,6 +218,11 @@ private async Task HandleInvocationAsync( "INPUT_TOO_LARGE", "command parameters exceed the runtime limit"); } + var activation = TryActivateAdmission(invocation); + if (activation == AdmissionActivation.Missing) + return ResultFailure(invocation.Id, "ADMISSION_REQUIRED", "invocation was not admitted by the Windows host"); + if (activation == AdmissionActivation.Mismatch) + return ResultFailure(invocation.Id, "ADMISSION_MISMATCH", "invocation changed after Windows admission"); if (invocation.NodeId != _nodeId || !_commands.Contains(invocation.Command)) { ReleaseAdmission(invocation.Id); @@ -365,7 +365,7 @@ private bool TryAdmit(SidecarInvocation invocation) { return false; } - _admittedInvocations.Add(invocation.Id, new Admission(invocation.CanonicalJson)); + _admittedInvocations.Add(invocation.Id, new Admission(invocation)); return true; } } @@ -376,7 +376,7 @@ private AdmissionActivation TryActivateAdmission(SidecarInvocation invocation) { if (!_admittedInvocations.TryGetValue(invocation.Id, out var admission) || admission.Active) return AdmissionActivation.Missing; - if (!SidecarJson.ValueEquals(admission.CanonicalJson, invocation.CanonicalJson)) + if (!InvocationEquals(admission.Invocation, invocation)) { _admittedInvocations.Remove(invocation.Id); return AdmissionActivation.Mismatch; @@ -392,6 +392,15 @@ private void ReleaseAdmission(string invocationId) _admittedInvocations.Remove(invocationId); } + private static bool InvocationEquals(SidecarInvocation left, SidecarInvocation right) => + left.Id == right.Id && + left.NodeId == right.NodeId && + left.Command == right.Command && + left.TimeoutMs == right.TimeoutMs && + left.IdempotencyKey == right.IdempotencyKey && + left.SessionKey == right.SessionKey && + SidecarJson.ValueEquals(left.Parameters, right.Parameters); + private static JsonObject Denial(string code, string message) => new() { ["outcome"] = "deny", @@ -556,7 +565,7 @@ private static SidecarInvocation ParseInvocation(JsonElement invocation) if (!invocation.TryGetProperty("params", out var parameters)) throw new SidecarProtocolException("Sidecar invocation is missing params."); var timeoutMs = OptionalUInt64(invocation, "timeoutMs"); - _ = OptionalString(invocation, "idempotencyKey"); + var idempotencyKey = OptionalString(invocation, "idempotencyKey"); var id = SidecarJson.RequiredString(invocation, "id"); var nodeId = SidecarJson.RequiredString(invocation, "nodeId"); var command = SidecarJson.RequiredString(invocation, "command"); @@ -566,10 +575,10 @@ private static SidecarInvocation ParseInvocation(JsonElement invocation) id, nodeId, command, - parameters.Clone(), + SidecarJson.NormalizeValue(parameters), timeoutMs, - OptionalString(invocation, "sessionKey"), - invocation.Clone()); + idempotencyKey, + OptionalString(invocation, "sessionKey")); } private static string? OptionalString(JsonElement parent, string name) @@ -657,12 +666,12 @@ private sealed record SidecarInvocation( string Command, JsonElement Parameters, ulong? TimeoutMs, - string? SessionKey, - JsonElement CanonicalJson); + string? IdempotencyKey, + string? SessionKey); - private sealed class Admission(JsonElement canonicalJson) + private sealed class Admission(SidecarInvocation invocation) { - internal JsonElement CanonicalJson { get; } = canonicalJson; + internal SidecarInvocation Invocation { get; } = invocation; internal bool Active { get; set; } internal bool CancellationRequested { get; set; } } diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index f71b88f8f..32ff4b57d 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -978,6 +978,64 @@ public async Task Adapter_TreatsReorderedInvocationObjectsAsUnchanged() 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() { @@ -1055,7 +1113,7 @@ public async Task Adapter_ReleasesAdmissionWhenInvocationChangesToOversizedInput var mismatch = await adapter.HandleRuntimeMessageAsync(changed, CancellationToken.None); var nextDecision = await adapter.HandleRuntimeMessageAsync(next, CancellationToken.None); - Assert.Equal("ADMISSION_MISMATCH", mismatch!["result"]!["code"]!.GetValue()); + Assert.Equal("INPUT_TOO_LARGE", mismatch!["result"]!["code"]!.GetValue()); Assert.Equal("allow", nextDecision!["decision"]!["outcome"]!.GetValue()); } From e0184d0dd9a62cf43578e28736784401e442a42e Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 03:22:12 -0700 Subject: [PATCH 15/33] fix: preserve active sidecar cancellation --- .../WindowsSidecarCapabilityAdapter.cs | 15 +++- .../WindowsSidecarCapabilityAdapterTests.cs | 72 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index a9c184ec9..91fee1fb7 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -207,12 +207,12 @@ private async Task HandleInvocationAsync( var invocation = ParseInvocation(SidecarJson.RequiredObject(message, "invocation")); if (!SidecarJson.IsPortableJson(invocation.Parameters)) { - ReleaseAdmission(invocation.Id); + ReleasePendingAdmission(invocation.Id); return NonPortableJsonFailure(invocation.Id); } if (!InputWithinLimit(invocation.Parameters)) { - ReleaseAdmission(invocation.Id); + ReleasePendingAdmission(invocation.Id); return ResultFailure( invocation.Id, "INPUT_TOO_LARGE", @@ -232,7 +232,7 @@ private async Task HandleInvocationAsync( if (executionTimeout == TimeSpan.Zero) { ReleaseAdmission(invocation.Id); - return ResultFailure(invocation.Id, "HANDLER_TIMEOUT", "command handler exceeded its deadline"); + return ResultFailure(invocation.Id, "HANDLER_TIMEOUT", "command handler deadline already elapsed"); } var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -392,6 +392,15 @@ private void ReleaseAdmission(string invocationId) _admittedInvocations.Remove(invocationId); } + private void ReleasePendingAdmission(string invocationId) + { + lock (_admissionLock) + { + if (_admittedInvocations.TryGetValue(invocationId, out var admission) && !admission.Active) + _admittedInvocations.Remove(invocationId); + } + } + private static bool InvocationEquals(SidecarInvocation left, SidecarInvocation right) => left.Id == right.Id && left.NodeId == right.NodeId && diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 32ff4b57d..434631832 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -1196,6 +1196,78 @@ public async Task Adapter_ClampsAndEnforcesNegotiatedInvocationTimeout() await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + [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)); + } + [Fact] public void Adapter_RecordsCurrentRustSystemNamespaceGapInsteadOfSelectingIt() { From 6b7fd1f5527b131b11e8488714be8dac438fcd92 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 03:31:03 -0700 Subject: [PATCH 16/33] fix: retain timed-out sidecar admissions --- .../WindowsSidecarCapabilityAdapter.cs | 27 ++++++++-- .../WindowsSidecarCapabilityAdapterTests.cs | 53 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 91fee1fb7..52cbdff91 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -236,6 +236,7 @@ private async Task HandleInvocationAsync( } var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseAdmissionOnExit = true; var request = new NodeInvokeRequest { Id = invocation.Id, @@ -260,13 +261,27 @@ private async Task HandleInvocationAsync( request, async response => { - completion.TrySetResult(response.Ok - ? await BuildSuccessResultAsync(invocation.Id, response.Payload) - : await BuildCapabilityFailureAsync(invocation.Id, response.Error)); + try + { + completion.TrySetResult(response.Ok + ? await BuildSuccessResultAsync(invocation.Id, response.Payload) + : await BuildCapabilityFailureAsync(invocation.Id, response.Error)); + } + finally + { + ReleaseAdmission(invocation.Id); + } }, error => { - completion.TrySetResult(ResultFailure(invocation.Id, "WINDOWS_CAPABILITY", error)); + try + { + completion.TrySetResult(ResultFailure(invocation.Id, "WINDOWS_CAPABILITY", error)); + } + finally + { + ReleaseAdmission(invocation.Id); + } return Task.CompletedTask; }, connectionCancellation); @@ -280,6 +295,7 @@ private async Task HandleInvocationAsync( } catch (TimeoutException) { + releaseAdmissionOnExit = false; _dispatcher.TryCancel(invocation.Id); return ResultFailure(invocation.Id, "HANDLER_TIMEOUT", "command handler exceeded its deadline"); } @@ -291,7 +307,8 @@ private async Task HandleInvocationAsync( } finally { - ReleaseAdmission(invocation.Id); + if (releaseAdmissionOnExit) + ReleaseAdmission(invocation.Id); } } diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 434631832..57ee10bf7 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -1196,6 +1196,59 @@ public async Task Adapter_ClampsAndEnforcesNegotiatedInvocationTimeout() await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + [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() { From 6ba6433d15c1d391734c44ac5231c8586c1bb3e1 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 03:40:23 -0700 Subject: [PATCH 17/33] fix: reserve system namespace case-insensitively --- .../RustSidecar/WindowsSidecarCapabilityAdapter.cs | 4 +++- .../RustSidecar/WindowsSidecarCapabilityAdapterTests.cs | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 52cbdff91..7197eb83b 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -641,7 +641,9 @@ private static void ValidateNames(IReadOnlyList capabilities, IReadOnlyL throw new SidecarProtocolException($"Invalid sidecar manifest name '{name}'."); } } - if (commands.Any(command => command == "system" || command.StartsWith("system.", StringComparison.Ordinal))) + if (commands.Any(command => + command.Equals("system", StringComparison.OrdinalIgnoreCase) || + command.StartsWith("system.", StringComparison.OrdinalIgnoreCase))) { throw new SidecarProtocolException( "The current OpenClaw sidecar bridge does not yet admit the system command namespace."); diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 57ee10bf7..b3913f21f 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -1321,13 +1321,15 @@ public async Task Adapter_MalformedDuplicateInvokeCannotBreakActiveCancellation( _ = await active.WaitAsync(TimeSpan.FromSeconds(5)); } - [Fact] - public void Adapter_RecordsCurrentRustSystemNamespaceGapInsteadOfSelectingIt() + [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", - "system.run", + command, (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true }))); var error = Assert.Throws(() => adapter.BeginConfiguration( From 314f964e256f7c34cdbbdf71cdf64a12296dd821 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 03:51:23 -0700 Subject: [PATCH 18/33] fix: stabilize malformed capability errors --- .../WindowsSidecarCapabilityAdapter.cs | 7 ++++++ .../WindowsSidecarCapabilityAdapterTests.cs | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 7197eb83b..39c19bcd2 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -537,6 +537,13 @@ await JsonSerializer.SerializeAsync( { return OutputTooLargeFailure(invocationId); } + catch (Exception) + { + return ResultFailure( + invocationId, + "RESULT_SERIALIZATION", + "Windows capability result could not be serialized"); + } } private static JsonObject ResultFailure(string invocationId, string code, string message) => new() diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index b3913f21f..c6c021873 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -612,6 +612,29 @@ public async Task Adapter_CountsLogicalOutputUsingSerdeCompatibleUtf8() 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_AllowsSerdeDepthCapabilityOutput() { From 923fcad13f971363a58f3d52bae3c3d93054c0b6 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 04:08:57 -0700 Subject: [PATCH 19/33] fix: preserve admitted numeric payloads --- .../RustSidecar/SidecarContracts.cs | 6 ++-- .../WindowsSidecarCapabilityAdapterTests.cs | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 4ed8dd765..76a31328f 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -175,9 +175,9 @@ private static bool NumberEquals(JsonElement left, JsonElement right) 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) + // A Windows capability can observe both the decimal value and raw JSON token. + // Do not round distinct admitted tokens through double before dispatch. + _ => left.GetRawText() == right.GetRawText() }; } diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index c6c021873..80ac08cef 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -979,6 +979,34 @@ public async Task Adapter_RequiresAnUnchangedAdmissionBeforeDispatch() Assert.Equal(0, executions); } + [Fact] + public async Task Adapter_RejectsFloatingPointTokenChangedAfterAdmission() + { + 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("ADMISSION_MISMATCH", result!["result"]!["code"]!.GetValue()); + Assert.Equal(0, executions); + } + [Fact] public async Task Adapter_TreatsReorderedInvocationObjectsAsUnchanged() { From 4439d45323f490998d317dcc4c127b2fed350ea4 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 04:21:17 -0700 Subject: [PATCH 20/33] fix: reserve default timeout result grace --- .../WindowsSidecarCapabilityAdapter.cs | 6 ++--- .../WindowsSidecarCapabilityAdapterTests.cs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 39c19bcd2..5a02584ba 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -581,9 +581,9 @@ private bool InputWithinLimit(JsonElement parameters) => { if (requestedTimeoutMs == 0) return null; - if (requestedTimeoutMs is null) - return TimeSpan.FromMilliseconds(_configuration!.DefaultTimeoutMs); - var bounded = Math.Min(requestedTimeoutMs.Value, _configuration!.MaxTimeoutMs); + var bounded = requestedTimeoutMs is null + ? _configuration!.DefaultTimeoutMs + : Math.Min(requestedTimeoutMs.Value, _configuration!.MaxTimeoutMs); var effective = bounded > _configuration.ResultGraceMs ? bounded - _configuration.ResultGraceMs : 0; diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 80ac08cef..583c18ea9 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -1247,6 +1247,33 @@ public async Task Adapter_ClampsAndEnforcesNegotiatedInvocationTimeout() await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + [Fact] + public async Task Adapter_ReservesResultGraceFromDefaultInvocationTimeout() + { + 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("HANDLER_TIMEOUT", result["result"]!["code"]!.GetValue()); + } + [Fact] public async Task Adapter_HoldsTimedOutAdmissionUntilNonCooperativeHandlerTerminates() { From a6f782a50e82a7c5672793302bdca9842dc516a7 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 04:32:19 -0700 Subject: [PATCH 21/33] fix: match Rust sidecar value semantics --- src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs | 6 +++--- .../RustSidecar/WindowsSidecarCapabilityAdapter.cs | 6 +++--- .../WindowsSidecarCapabilityAdapterTests.cs | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 76a31328f..4ed8dd765 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -175,9 +175,9 @@ private static bool NumberEquals(JsonElement left, JsonElement right) left.TryGetInt64(out var leftSigned) && right.TryGetInt64(out var rightSigned) && leftSigned == rightSigned, - // A Windows capability can observe both the decimal value and raw JSON token. - // Do not round distinct admitted tokens through double before dispatch. - _ => left.GetRawText() == right.GetRawText() + _ => left.TryGetDouble(out var leftFloat) && + right.TryGetDouble(out var rightFloat) && + leftFloat.Equals(rightFloat) }; } diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 5a02584ba..39c19bcd2 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -581,9 +581,9 @@ private bool InputWithinLimit(JsonElement parameters) => { if (requestedTimeoutMs == 0) return null; - var bounded = requestedTimeoutMs is null - ? _configuration!.DefaultTimeoutMs - : Math.Min(requestedTimeoutMs.Value, _configuration!.MaxTimeoutMs); + if (requestedTimeoutMs is null) + return TimeSpan.FromMilliseconds(_configuration!.DefaultTimeoutMs); + var bounded = Math.Min(requestedTimeoutMs.Value, _configuration!.MaxTimeoutMs); var effective = bounded > _configuration.ResultGraceMs ? bounded - _configuration.ResultGraceMs : 0; diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 583c18ea9..8ceed27ab 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -980,7 +980,7 @@ public async Task Adapter_RequiresAnUnchangedAdmissionBeforeDispatch() } [Fact] - public async Task Adapter_RejectsFloatingPointTokenChangedAfterAdmission() + public async Task Adapter_UsesSerdeFloatingPointEqualityForAdmission() { var executions = 0; var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger()); @@ -1003,8 +1003,8 @@ public async Task Adapter_RejectsFloatingPointTokenChangedAfterAdmission() _ = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None); var result = await adapter.HandleRuntimeMessageAsync(invocation, CancellationToken.None); - Assert.Equal("ADMISSION_MISMATCH", result!["result"]!["code"]!.GetValue()); - Assert.Equal(0, executions); + Assert.Equal("success", result!["result"]!["outcome"]!.GetValue()); + Assert.Equal(1, executions); } [Fact] @@ -1248,7 +1248,7 @@ public async Task Adapter_ClampsAndEnforcesNegotiatedInvocationTimeout() } [Fact] - public async Task Adapter_ReservesResultGraceFromDefaultInvocationTimeout() + public async Task Adapter_UsesFullDefaultInvocationTimeoutLikeRustRuntime() { var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger()); adapter.RegisterCapability(new TestCapability( @@ -1271,7 +1271,7 @@ public async Task Adapter_ReservesResultGraceFromDefaultInvocationTimeout() var result = await InvokeAsync(adapter, invocation).WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal("HANDLER_TIMEOUT", result["result"]!["code"]!.GetValue()); + Assert.Equal("success", result["result"]!["outcome"]!.GetValue()); } [Fact] From 6cd11d8f14f635454b8b235a6c6c1aae0e3936f3 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 04:54:49 -0700 Subject: [PATCH 22/33] fix: align sidecar result serialization --- .../RustSidecar/SidecarContracts.cs | 106 +++++++++++++++++- .../WindowsSidecarCapabilityAdapter.cs | 6 +- .../WindowsSidecarCapabilityAdapterTests.cs | 61 ++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 4ed8dd765..073939a91 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -1,6 +1,8 @@ +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; @@ -84,7 +86,12 @@ internal static class SidecarJson PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, MaxDepth = MaxDepth, - Encoder = SerdeJsonEncoder.Instance + Encoder = SerdeJsonEncoder.Instance, + Converters = + { + SerdeDoubleConverter.Instance, + SerdeSingleConverter.Instance + } }; internal static byte[] Serialize(JsonNode node) => @@ -203,6 +210,103 @@ private sealed class JsonElementValueComparer : IEqualityComparer 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); + 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 is >= -5 and <= 15) + 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 > 16) + { + 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) => 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) => writer.WriteRawValue(FormatSerdeFloat(value)); + } + private sealed unsafe class SerdeJsonEncoder : JavaScriptEncoder { internal static readonly SerdeJsonEncoder Instance = new(); diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 39c19bcd2..45cf1aa81 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -272,17 +272,17 @@ private async Task HandleInvocationAsync( ReleaseAdmission(invocation.Id); } }, - error => + async error => { try { - completion.TrySetResult(ResultFailure(invocation.Id, "WINDOWS_CAPABILITY", error)); + completion.TrySetResult( + await BuildCapabilityFailureAsync(invocation.Id, error)); } finally { ReleaseAdmission(invocation.Id); } - return Task.CompletedTask; }, connectionCancellation); } diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 8ceed27ab..f7e303a02 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -635,6 +635,28 @@ public async Task Adapter_ReturnsStableFailureForInvalidUtf16CapabilityError() 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() { @@ -656,6 +678,45 @@ public async Task Adapter_AllowsSerdeDepthCapabilityOutput() 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, + singleExponent = 1e-7f + } + }))); + 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("\"singleExponent\":1.0000000116860974e-7", encoded); + 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_RejectsNonPortableIntegerCapabilityOutput() { From 00931debe40dfc9bd5e351c6bb131e257e5d40dc Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 05:11:35 -0700 Subject: [PATCH 23/33] fix: separate handler and result deadlines --- .../WindowsSidecarCapabilityAdapter.cs | 6 ++- .../WindowsSidecarCapabilityAdapterTests.cs | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index 45cf1aa81..ebebb991f 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -236,6 +236,7 @@ private async Task HandleInvocationAsync( } var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handlerCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseAdmissionOnExit = true; var request = new NodeInvokeRequest { @@ -261,6 +262,7 @@ private async Task HandleInvocationAsync( request, async response => { + handlerCompleted.TrySetResult(); try { completion.TrySetResult(response.Ok @@ -274,6 +276,7 @@ private async Task HandleInvocationAsync( }, async error => { + handlerCompleted.TrySetResult(); try { completion.TrySetResult( @@ -291,7 +294,7 @@ private async Task HandleInvocationAsync( return await completion.Task.WaitAsync(connectionCancellation); try { - return await completion.Task.WaitAsync(executionTimeout.Value, connectionCancellation); + await handlerCompleted.Task.WaitAsync(executionTimeout.Value, connectionCancellation); } catch (TimeoutException) { @@ -299,6 +302,7 @@ private async Task HandleInvocationAsync( _dispatcher.TryCancel(invocation.Id); return ResultFailure(invocation.Id, "HANDLER_TIMEOUT", "command handler exceeded its deadline"); } + return await completion.Task.WaitAsync(connectionCancellation); } catch (OperationCanceledException) { diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index f7e303a02..86f575ea6 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -717,6 +717,34 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() SidecarJson.SerializerOptions))); } + [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() { @@ -1628,6 +1656,18 @@ private static SidecarProtocolOffer ParseOffer(JsonElement offer) 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, From 60733933df69b04210c6e5520059903a54fb8e2f Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 05:25:32 -0700 Subject: [PATCH 24/33] fix: normalize untyped sidecar numbers --- .../RustSidecar/SidecarContracts.cs | 18 +++++++++++++++ .../WindowsSidecarCapabilityAdapter.cs | 11 +++++++-- .../WindowsSidecarCapabilityAdapterTests.cs | 23 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 073939a91..b98ac3fe4 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -142,6 +142,8 @@ internal static JsonElement NormalizeValue(JsonElement value) 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(), @@ -149,6 +151,22 @@ internal static JsonElement NormalizeValue(JsonElement value) } } + 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); diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index ebebb991f..e9e2e2816 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -477,10 +477,17 @@ await JsonSerializer.SerializeAsync( payload?.GetType() ?? typeof(object), SidecarJson.SerializerOptions); var payloadJson = output.WrittenMemory.Span; - if (!SidecarJson.IsPortableJson(SidecarJson.Parse(payloadJson))) + var parsedPayload = SidecarJson.Parse(payloadJson); + if (!SidecarJson.IsPortableJson(parsedPayload)) return NonPortableJsonFailure(invocationId); + var normalizedPayload = SidecarJson.NormalizeValue(parsedPayload); + var normalizedJson = JsonSerializer.SerializeToUtf8Bytes( + normalizedPayload, + SidecarJson.SerializerOptions); + if (normalizedJson.Length > _configuration.MaxOutputBytes) + return OutputTooLargeFailure(invocationId); var payloadNode = JsonNode.Parse( - payloadJson, + normalizedJson, documentOptions: new JsonDocumentOptions { MaxDepth = SidecarJson.MaxDepth }); return new JsonObject { diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 86f575ea6..8275fd473 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -717,6 +717,29 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() SidecarJson.SerializerOptions))); } + [Fact] + public async Task Adapter_NormalizesUntypedCapabilityNumbersLikeSerdeJson() + { + var payload = SidecarJson.Parse( + """{"integral":1e0,"rounded":1.0000000000000001,"fixedSmall":1e-5}"""u8); + 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}", + result["result"]!["payload"]!.ToJsonString(SidecarJson.SerializerOptions)); + } + [Fact] public async Task Adapter_DoesNotChargeResultSerializationToHandlerDeadline() { From 1d116878bda754957279324d68db1f164e2480a9 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 05:41:02 -0700 Subject: [PATCH 25/33] fix: match serde result number encoding --- .../RustSidecar/SidecarContracts.cs | 55 +++++++++++++++++-- .../WindowsSidecarCapabilityAdapterTests.cs | 15 +++-- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index b98ac3fe4..df61c6fa9 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -90,7 +90,8 @@ internal static class SidecarJson Converters = { SerdeDoubleConverter.Instance, - SerdeSingleConverter.Instance + SerdeSingleConverter.Instance, + SerdeJsonElementConverter.Instance } }; @@ -232,7 +233,18 @@ 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); + return FormatSerdeFloatText(value.ToString("R", CultureInfo.InvariantCulture)); + } + + private static string FormatSerdeFloat(float value) + { + if (!float.IsFinite(value)) + throw new JsonException("Sidecar JSON cannot encode non-finite floating-point values."); + return FormatSerdeFloatText(value.ToString("R", CultureInfo.InvariantCulture)); + } + + private static string FormatSerdeFloatText(string text) + { var exponentIndex = text.IndexOf('E'); if (exponentIndex >= 0) { @@ -307,7 +319,15 @@ public override double Read( public override void Write( Utf8JsonWriter writer, double value, - JsonSerializerOptions options) => writer.WriteRawValue(FormatSerdeFloat(value)); + JsonSerializerOptions options) + { + if (!double.IsFinite(value)) + { + writer.WriteNullValue(); + return; + } + writer.WriteRawValue(FormatSerdeFloat(value)); + } } private sealed class SerdeSingleConverter : JsonConverter @@ -322,7 +342,34 @@ public override float Read( public override void Write( Utf8JsonWriter writer, float value, - JsonSerializerOptions options) => writer.WriteRawValue(FormatSerdeFloat(value)); + JsonSerializerOptions options) + { + if (!float.IsFinite(value)) + { + writer.WriteNullValue(); + return; + } + writer.WriteRawValue(FormatSerdeFloat(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) => NormalizeValue(value).WriteTo(writer); } private sealed unsafe class SerdeJsonEncoder : JavaScriptEncoder diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 8275fd473..399ac2f8a 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -693,7 +693,9 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() doubleIntegral = 1.0, negativeZero = -0.0, doubleExponent = 1.25e-7, - singleExponent = 1e-7f + singleExponent = 1e-7f, + nonFiniteDouble = double.NaN, + nonFiniteSingle = float.PositiveInfinity } }))); Configure(adapter, maxOutputBytes: 1024); @@ -709,7 +711,9 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() Assert.Contains("\"doubleIntegral\":1.0", encoded); Assert.Contains("\"negativeZero\":-0.0", encoded); Assert.Contains("\"doubleExponent\":1.25e-7", encoded); - Assert.Contains("\"singleExponent\":1.0000000116860974e-7", encoded); + Assert.Contains("\"singleExponent\":1e-7", encoded); + Assert.Contains("\"nonFiniteDouble\":null", encoded); + Assert.Contains("\"nonFiniteSingle\":null", encoded); Assert.Equal( "{\"fixedSmall\":0.00001,\"scientificLarge\":1e+16}", Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes( @@ -720,8 +724,9 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() [Fact] public async Task Adapter_NormalizesUntypedCapabilityNumbersLikeSerdeJson() { - var payload = SidecarJson.Parse( - """{"integral":1e0,"rounded":1.0000000000000001,"fixedSmall":1e-5}"""u8); + var redundantFraction = "1." + new string('0', 2048); + var payload = SidecarJson.Parse(Encoding.UTF8.GetBytes( + $$"""{"integral":1e0,"rounded":1.0000000000000001,"fixedSmall":1e-5,"compacted":{{redundantFraction}}}""")); var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger()); adapter.RegisterCapability(new TestCapability( "native.status", @@ -736,7 +741,7 @@ public async Task Adapter_NormalizesUntypedCapabilityNumbersLikeSerdeJson() var result = await InvokeAsync(adapter, invocation); Assert.Equal( - "{\"integral\":1.0,\"rounded\":1.0,\"fixedSmall\":0.00001}", + "{\"integral\":1.0,\"rounded\":1.0,\"fixedSmall\":0.00001,\"compacted\":1.0}", result["result"]!["payload"]!.ToJsonString(SidecarJson.SerializerOptions)); } From d4d8ef7fe2290c84c40c6d08eb71285cc7beec98 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 05:51:49 -0700 Subject: [PATCH 26/33] fix: bound canonical JSON serialization --- .../RustSidecar/SidecarContracts.cs | 195 +++++++++++++++++- .../WindowsSidecarCapabilityAdapterTests.cs | 35 +++- 2 files changed, 221 insertions(+), 9 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index df61c6fa9..8356ee4fa 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -91,7 +91,8 @@ internal static class SidecarJson { SerdeDoubleConverter.Instance, SerdeSingleConverter.Instance, - SerdeJsonElementConverter.Instance + SerdeJsonElementConverter.Instance, + SerdeJsonNodeConverterFactory.Instance } }; @@ -233,17 +234,37 @@ private static string FormatSerdeFloat(double value) { if (!double.IsFinite(value)) throw new JsonException("Sidecar JSON cannot encode non-finite floating-point values."); - return FormatSerdeFloatText(value.ToString("R", CultureInfo.InvariantCulture)); + 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."); - return FormatSerdeFloatText(value.ToString("R", CultureInfo.InvariantCulture)); + 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) + private static string FormatSerdeFloatText( + string text, + int minimumFixedExponent, + int maximumFixedExponent, + int maximumFixedIntegerDigits) { var exponentIndex = text.IndexOf('E'); if (exponentIndex >= 0) @@ -253,7 +274,7 @@ private static string FormatSerdeFloatText(string text) NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); var mantissa = text[..exponentIndex]; - if (exponent is >= -5 and <= 15) + if (exponent >= minimumFixedExponent && exponent <= maximumFixedExponent) return ExpandSerdeFloat(mantissa, exponent); return string.Concat( mantissa, @@ -263,7 +284,7 @@ private static string FormatSerdeFloatText(string text) var unsigned = text[0] == '-' ? text[1..] : text; var decimalPoint = unsigned.IndexOf('.'); var integerDigits = decimalPoint >= 0 ? decimalPoint : unsigned.Length; - if (integerDigits > 16) + if (integerDigits > maximumFixedIntegerDigits) { var digits = unsigned.Replace(".", string.Empty, StringComparison.Ordinal) .TrimEnd('0'); @@ -369,7 +390,167 @@ public override JsonElement Read( public override void Write( Utf8JsonWriter writer, JsonElement value, - JsonSerializerOptions options) => NormalizeValue(value).WriteTo(writer); + JsonSerializerOptions options) => WriteNormalizedValue(writer, value); + + 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 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) => 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 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 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 when jsonValue.TryGetValue(out var element): + SerdeJsonElementConverter.WriteNormalizedValue(writer, element); + return; + case JsonValue jsonValue: + jsonValue.WriteTo(writer); + return; + default: + throw new JsonException("Sidecar JSON contains an unsupported node type."); + } + } } private sealed unsafe class SerdeJsonEncoder : JavaScriptEncoder diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 399ac2f8a..b2f1e94ff 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -693,7 +693,10 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() 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 } @@ -711,7 +714,10 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() 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.Equal( @@ -726,7 +732,7 @@ 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}}}""")); + $$"""{"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", @@ -741,7 +747,32 @@ public async Task Adapter_NormalizesUntypedCapabilityNumbersLikeSerdeJson() var result = await InvokeAsync(adapter, invocation); Assert.Equal( - "{\"integral\":1.0,\"rounded\":1.0,\"fixedSmall\":0.00001,\"compacted\":1.0}", + "{\"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; + 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); + + Assert.Equal( + "{\"compacted\":1.0,\"nan\":null,\"infinity\":null}", result["result"]!["payload"]!.ToJsonString(SidecarJson.SerializerOptions)); } From b6d78ebb3dec106d5fa70c7ed9790e9d05b0cc84 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 05:56:50 -0700 Subject: [PATCH 27/33] fix: normalize JsonDocument sidecar results --- .../RustSidecar/SidecarContracts.cs | 17 ++++++++++++++ .../WindowsSidecarCapabilityAdapterTests.cs | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 8356ee4fa..bac3db079 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -92,6 +92,7 @@ internal static class SidecarJson SerdeDoubleConverter.Instance, SerdeSingleConverter.Instance, SerdeJsonElementConverter.Instance, + SerdeJsonDocumentConverter.Instance, SerdeJsonNodeConverterFactory.Instance } }; @@ -471,6 +472,22 @@ public override JsonConverter CreateConverter( 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.WriteNormalizedValue(writer, value.RootElement); + } + private sealed class SerdeJsonNodeConverter : JsonConverter where TNode : JsonNode { diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index b2f1e94ff..61dc02a1e 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -776,6 +776,29 @@ public async Task Adapter_NormalizesJsonNodeCapabilityNumbersLikeSerdeJson() result["result"]!["payload"]!.ToJsonString(SidecarJson.SerializerOptions)); } + [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_DoesNotChargeResultSerializationToHandlerDeadline() { From 9abed58dc09b3437d1e30b7dce2fe6818726956d Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 06:04:33 -0700 Subject: [PATCH 28/33] fix: bound JSON canonicalization work --- .../RustSidecar/SidecarContracts.cs | 104 +++++++++++++++++- .../WindowsSidecarCapabilityAdapter.cs | 17 ++- .../WindowsSidecarCapabilityAdapterTests.cs | 33 +++++- 3 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index bac3db079..4832b2750 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; @@ -80,6 +81,9 @@ 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 static readonly AsyncLocal CurrentCanonicalizationBudget = new(); internal static readonly JsonSerializerOptions SerializerOptions = new() { @@ -100,6 +104,19 @@ internal static class SidecarJson 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( @@ -391,7 +408,27 @@ public override JsonElement Read( public override void Write( Utf8JsonWriter writer, JsonElement value, - JsonSerializerOptions options) => WriteNormalizedValue(writer, 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 WriteNormalizedValue(Utf8JsonWriter writer, JsonElement value) { @@ -484,8 +521,11 @@ public override JsonDocument Read( public override void Write( Utf8JsonWriter writer, JsonDocument value, - JsonSerializerOptions options) => + JsonSerializerOptions options) + { + SerdeJsonElementConverter.PreflightRawValue(value.RootElement); SerdeJsonElementConverter.WriteNormalizedValue(writer, value.RootElement); + } } private sealed class SerdeJsonNodeConverter : JsonConverter @@ -552,6 +592,9 @@ private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node) 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; @@ -559,6 +602,7 @@ private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node) SerdeSingleConverter.Instance.Write(writer, singleValue, SerializerOptions); return; case JsonValue jsonValue when jsonValue.TryGetValue(out var element): + SerdeJsonElementConverter.PreflightRawValue(element); SerdeJsonElementConverter.WriteNormalizedValue(writer, element); return; case JsonValue jsonValue: @@ -570,6 +614,60 @@ private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node) } } + 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; + } + } + + 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 = checked((int)budget.RemainingBytes); + if (requested == 0 || sizeHint > requested) + 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(); @@ -712,3 +810,5 @@ private static bool IsPortableNumber(JsonElement value) return floating != Math.Truncate(floating) || Math.Abs(floating) <= MaxPortableInteger; } } + +internal sealed class SidecarCanonicalizationLimitException : Exception; diff --git a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs index e9e2e2816..d2ee376f7 100644 --- a/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs +++ b/src/OpenClaw.Shared/RustSidecar/WindowsSidecarCapabilityAdapter.cs @@ -471,11 +471,14 @@ private async Task BuildSuccessResultAsync(string invocationId, obje try { using var output = new BoundedWriteStream(_configuration!.MaxOutputBytes); - await JsonSerializer.SerializeAsync( - output, - payload, - payload?.GetType() ?? typeof(object), - SidecarJson.SerializerOptions); + using (SidecarJson.BeginCanonicalizationBudget(_configuration.MaxOutputBytes)) + { + await JsonSerializer.SerializeAsync( + output, + payload, + payload?.GetType() ?? typeof(object), + SidecarJson.SerializerOptions); + } var payloadJson = output.WrittenMemory.Span; var parsedPayload = SidecarJson.Parse(payloadJson); if (!SidecarJson.IsPortableJson(parsedPayload)) @@ -504,6 +507,10 @@ await JsonSerializer.SerializeAsync( { return OutputTooLargeFailure(invocationId); } + catch (SidecarCanonicalizationLimitException) + { + return OutputTooLargeFailure(invocationId); + } catch (Exception) { return ResultFailure( diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 61dc02a1e..4a0b30d0d 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -758,6 +758,9 @@ public async Task Adapter_NormalizesJsonNodeCapabilityNumbersLikeSerdeJson() 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", @@ -771,9 +774,12 @@ public async Task Adapter_NormalizesJsonNodeCapabilityNumbersLikeSerdeJson() var result = await InvokeAsync(adapter, invocation); - Assert.Equal( - "{\"compacted\":1.0,\"nan\":null,\"infinity\":null}", - result["result"]!["payload"]!.ToJsonString(SidecarJson.SerializerOptions)); + 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] @@ -799,6 +805,27 @@ public async Task Adapter_NormalizesJsonDocumentCapabilityNumbersLikeSerdeJson() 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_DoesNotChargeResultSerializationToHandlerDeadline() { From fe790a6128ae1a1b1748879ecc49fbd97b68f7ff Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 06:07:10 -0700 Subject: [PATCH 29/33] fix: canonicalize typed decimals and parsed nodes --- .../RustSidecar/SidecarContracts.cs | 25 ++++++++++++++--- .../WindowsSidecarCapabilityAdapterTests.cs | 28 ++++++++++++++++++- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 4832b2750..2a56e84e8 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -95,6 +95,7 @@ internal static class SidecarJson { SerdeDoubleConverter.Instance, SerdeSingleConverter.Instance, + SerdeDecimalConverter.Instance, SerdeJsonElementConverter.Instance, SerdeJsonDocumentConverter.Instance, SerdeJsonNodeConverterFactory.Instance @@ -392,6 +393,22 @@ public override void Write( } } + 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(); @@ -568,6 +585,10 @@ private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node) WriteNormalizedNode(writer, item); writer.WriteEndArray(); return; + case JsonValue jsonValue when jsonValue.TryGetValue(out var element): + SerdeJsonElementConverter.PreflightRawValue(element); + SerdeJsonElementConverter.WriteNormalizedValue(writer, element); + return; case JsonValue jsonValue when jsonValue.TryGetValue(out var byteValue): writer.WriteNumberValue(byteValue); return; @@ -601,10 +622,6 @@ private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node) case JsonValue jsonValue when jsonValue.TryGetValue(out var singleValue): SerdeSingleConverter.Instance.Write(writer, singleValue, SerializerOptions); return; - case JsonValue jsonValue when jsonValue.TryGetValue(out var element): - SerdeJsonElementConverter.PreflightRawValue(element); - SerdeJsonElementConverter.WriteNormalizedValue(writer, element); - return; case JsonValue jsonValue: jsonValue.WriteTo(writer); return; diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 4a0b30d0d..49098112b 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -698,7 +698,10 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() singleFixedBoundary = 1e-6f, singleScientificBoundary = 1e13f, nonFiniteDouble = double.NaN, - nonFiniteSingle = float.PositiveInfinity + nonFiniteSingle = float.PositiveInfinity, + typedDecimals = Enumerable.Repeat( + 1.0000000000000000000000000000m, + 40).ToArray() } }))); Configure(adapter, maxOutputBytes: 1024); @@ -720,6 +723,8 @@ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson() 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( @@ -826,6 +831,27 @@ public async Task Adapter_BoundsUntypedCanonicalizationWork() 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_DoesNotChargeResultSerializationToHandlerDeadline() { From b551e3083dfda0463b1d8b3ada82ac87d06743ee Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 06:11:36 -0700 Subject: [PATCH 30/33] fix: preflight lazy JSON node containers --- .../RustSidecar/SidecarContracts.cs | 32 +++++++++++++++++-- .../WindowsSidecarCapabilityAdapterTests.cs | 21 ++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index 2a56e84e8..f0cc17011 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -447,6 +447,31 @@ internal static void PreflightRawValue(JsonElement value) rawWriter.Flush(); } + internal static void PreflightRawNode(JsonNode 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 + }); + try + { + value.WriteTo(rawWriter); + rawWriter.Flush(); + } + catch (ArgumentException) + { + // Programmatically created JsonNode values may contain NaN or + // infinity. Their containers are already materialized; the + // normalized writer below maps those leaves to serde nulls. + } + } + internal static void WriteNormalizedValue(Utf8JsonWriter writer, JsonElement value) { switch (value.ValueKind) @@ -561,7 +586,11 @@ private sealed class SerdeJsonNodeConverter : JsonConverter public override void Write( Utf8JsonWriter writer, TNode value, - JsonSerializerOptions options) => WriteNormalizedNode(writer, value); + JsonSerializerOptions options) + { + SerdeJsonElementConverter.PreflightRawNode(value); + WriteNormalizedNode(writer, value); + } private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node) { @@ -586,7 +615,6 @@ private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node) writer.WriteEndArray(); return; case JsonValue jsonValue when jsonValue.TryGetValue(out var element): - SerdeJsonElementConverter.PreflightRawValue(element); SerdeJsonElementConverter.WriteNormalizedValue(writer, element); return; case JsonValue jsonValue when jsonValue.TryGetValue(out var byteValue): diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 49098112b..6e5ac01b9 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -852,6 +852,27 @@ public async Task Adapter_BoundsJsonNodeCanonicalizationWork() 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_DoesNotChargeResultSerializationToHandlerDeadline() { From a847e6be5d7c29f22bbcafcbc8dabcacb2a2d4c7 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 06:14:08 -0700 Subject: [PATCH 31/33] fix: continue node preflight after non-finite values --- .../RustSidecar/SidecarContracts.cs | 30 +++++++++++++++++-- .../WindowsSidecarCapabilityAdapterTests.cs | 25 ++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index f0cc17011..f25d34745 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -467,8 +467,34 @@ internal static void PreflightRawNode(JsonNode value) catch (ArgumentException) { // Programmatically created JsonNode values may contain NaN or - // infinity. Their containers are already materialized; the - // normalized writer below maps those leaves to serde nulls. + // 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. + 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; } } diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 6e5ac01b9..02a46abd7 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -873,6 +873,31 @@ public async Task Adapter_BoundsLazyJsonNodeContainerMaterialization() 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_DoesNotChargeResultSerializationToHandlerDeadline() { From ef338a3c106ad90cfdcb42ae884734e11fcb0256 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 31 Jul 2026 06:18:54 -0700 Subject: [PATCH 32/33] fix: avoid double-charging JSON preflight --- .../RustSidecar/SidecarContracts.cs | 11 +++++--- .../WindowsSidecarCapabilityAdapterTests.cs | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs index f25d34745..6cff79aee 100644 --- a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs +++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs @@ -83,6 +83,7 @@ internal static class SidecarJson 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() @@ -452,6 +453,7 @@ 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 { @@ -470,6 +472,8 @@ internal static void PreflightRawNode(JsonNode value) // 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); } } @@ -695,6 +699,8 @@ internal void Consume(int bytes) throw new SidecarCanonicalizationLimitException(); RemainingBytes -= bytes; } + + internal void Restore(long remainingBytes) => RemainingBytes = remainingBytes; } private sealed class CanonicalizationBudgetScope(CanonicalizationBudget? previous) : IDisposable @@ -726,9 +732,8 @@ public void Advance(int count) public Memory GetMemory(int sizeHint = 0) { var requested = Math.Max(sizeHint, 256); - if (requested > budget.RemainingBytes) - requested = checked((int)budget.RemainingBytes); - if (requested == 0 || sizeHint > requested) + if (requested > budget.RemainingBytes && + requested > MaximumCanonicalizationBufferSlackBytes) throw new SidecarCanonicalizationLimitException(); if (_buffer.Length < requested) _buffer = new byte[requested]; diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs index 02a46abd7..2800e47d4 100644 --- a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs +++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs @@ -898,6 +898,33 @@ public async Task Adapter_ContinuesJsonNodeWorkBudgetAfterNonFiniteValue() 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() { From 711fe095028a025fa0649c9b8e20f480644a6967 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 12 Aug 2026 12:20:11 -0700 Subject: [PATCH 33/33] test: preserve handshake authorization in runtime seam --- tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs b/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs index 393646e0a..1285665b2 100644 --- a/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs +++ b/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs @@ -49,6 +49,8 @@ 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; }