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