From c2e361780b42eec196ca0d00374f8147d6d038a6 Mon Sep 17 00:00:00 2001 From: clawsweeper <280513762+clawsweeper@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:57:02 +0000 Subject: [PATCH] feat(node): project truthful capability readiness --- docs/ARCHITECTURE.md | 3 +- docs/MCP_MODE.md | 6 + docs/WINDOWS_NODE_TESTING.md | 1 + .../WindowsNodeContextSection.cs | 2 +- .../CapabilityTruthProjection.cs | 220 +++++++++++++++++ src/OpenClaw.Shared/Mcp/McpToolBridge.cs | 2 +- src/OpenClaw.Shared/Models.cs | 1 + .../App.CapabilityHandlers.cs | 11 +- .../Helpers/CommandCenterTextHelper.cs | 13 + .../Services/CommandCenterStateBuilder.cs | 9 + .../ConnectionDiagnosticsProjection.cs | 7 +- .../Services/NodeCapabilityGating.cs | 9 +- .../Services/NodeCapabilityTruthSource.cs | 113 +++++++++ .../Services/NodeService.cs | 37 ++- src/OpenClaw.WinNode.Cli/skill.md | 8 +- .../SetupConfigTests.cs | 2 + .../CapabilityTruthProjectionTests.cs | 228 ++++++++++++++++++ .../McpToolBridgeTests.cs | 48 ++++ .../CommandCenterStateBuilderTests.cs | 15 ++ .../ConnectionDiagnosticsProjectionTests.cs | 33 +++ 20 files changed, 755 insertions(+), 13 deletions(-) create mode 100644 src/OpenClaw.Shared/CapabilityTruthProjection.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/NodeCapabilityTruthSource.cs create mode 100644 tests/OpenClaw.Shared.Tests/CapabilityTruthProjectionTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3784a30fa..fe0a6fe44 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Hub navigation tags, page mapping, command catalog/search, and gateway-page classification | `HubPageRegistry` | authoritative | | Hub notification banner severity and action projection | `AppNotificationInfoBarPresenter` | authoritative | | Tray-menu semantic composition and connection-toggle state | `TrayMenuPresenter` + `ConnectionTogglePresenter` | authoritative | -| Capability UI metadata | `NodeCapabilityUiCatalog` (planned) | planned | +| Capability state projection for camera, browser proxy, and system.run | `CapabilityTruthProjection` fed by `NodeCapabilityTruthSource` | authoritative | | Capability registration/gating | `NodeCapabilityRegistrationPolicy` (planned) | planned | | Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned | | Gateway connect envelope | `ConnectEnvelopeBuilder` (planned) | planned | @@ -141,6 +141,7 @@ leading and trailing pipe. Columns, in order: | connection-page-direct-connect-closed | closed | src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs | direct-connect registry, identity-token, settings, rollback, terminal-wait, and runtime-tunnel transaction | GatewayDirectConnectService | add-form control reads, input validation, result text, and post-success visual refresh only | the page delegates one request; rollback restores the durable registry before identity and settings, reconnects a previously live gateway, and a later credential writer wins | GatewayDirectConnectServiceTests.Connect_Failure_RestoresPreviousLiveConnection | behavioral | when the Connection page no longer contains any direct-connect persistence or rollback logic | | connection-status-direct-connect-closed | closed | src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs | direct-connect registry, settings, rollback, terminal-wait, and runtime-tunnel transaction | GatewayDirectConnectService | diagnostics control reads, input validation, and result text only | diagnostics direct connect delegates one request and cannot report success before a terminal manager state | AppRefactorContractTests.StatusWindowDirectConnect_WaitsForManagerStateBeforeReportingConnected | source-shape | when the status window no longer contains direct-connect persistence or rollback logic | | app-window-manager | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | window creation/show/hide/shutdown | IWindowManager | composition/delegation only | startup/shutdown ordering deterministic; disposed once | none | review-only | extracted in Phase 3 | +| capability-truth-projection | authoritative | NodeService, Command Center, and app.connection diagnostics | independent interpretations of settings, Windows permission, declaration, approval, and runtime readiness | CapabilityTruthProjection fed by NodeCapabilityTruthSource | existing owners continue to produce facts; projection owns no lifecycle or persistence | camera, browser proxy, and system.run report one fail-closed state to Command Center and local MCP without duplicating GatewayConnectionManager lifecycle | CapabilityTruthProjectionTests.Project_PendingGatewayDeclaration_IsNotReportedReady | behavioral | - | | app-tray-controller | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon/menu/action routing | ITrayController | composition/delegation only | tray actions route unchanged | none | review-only | extracted in Phase 3 | | app-activation-router | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | deep-link/toast/single-instance activation | IActivationRouter | composition/delegation only | activation routes land on the same UI/actions; current-user pipe security preserved | none | review-only | extracted in Phase 3 | | native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | provider calls the projector while retaining stateful live/history application and metadata cache behavior | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - | diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md index 6c038816b..6c6fbf255 100644 --- a/docs/MCP_MODE.md +++ b/docs/MCP_MODE.md @@ -25,6 +25,12 @@ The implementation is structured so that **adding a new node capability automati ## Architecture +`app.connection.status` exposes an agent-facing `capabilities` projection for +camera, browser proxy, and system.run. It keeps local MCP exposure distinct +from the Gateway's effective declaration and reports Settings, Windows +permission, approval, and runtime readiness without moving connection +lifecycle out of `GatewayConnectionManager`. + ### Single capability registry, two transports ![Single capability registry, two transports](diagrams/mcp-mode-dual-transport.svg) diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index ddacdb6c1..03e406be2 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -62,6 +62,7 @@ Every new Windows node call must be exposed through local MCP and `winnode`: reg - If the gateway omits a safe pending request ID, verify the copy action emits `openclaw nodes pending`, labels it as discovery only, and does not offer reconnect-after-approval yet - Approve the request explicitly, reconnect the node, and verify the effective capability/command counts update and the pending reapproval warning clears - Use "Copy fix" only for safe repair commands; privacy-sensitive commands remain informational unless you explicitly opt in on the gateway +- Copy **Capability diagnostics** and verify camera, browser proxy, and system.run each report Settings, Windows permission, Gateway declaration, approval, local MCP exposure, runtime readiness, an overall state, and a repair path. The same entries must appear in `app.connection.status`. ## What Requires Gateway Support diff --git a/src/OpenClaw.SetupEngine/WindowsNodeContextSection.cs b/src/OpenClaw.SetupEngine/WindowsNodeContextSection.cs index 7af849a59..42c0489c8 100644 --- a/src/OpenClaw.SetupEngine/WindowsNodeContextSection.cs +++ b/src/OpenClaw.SetupEngine/WindowsNodeContextSection.cs @@ -8,7 +8,7 @@ internal static class WindowsNodeContextSection public const string Payload = """ This WSL gateway may be paired with the OpenClaw Windows tray node. For Windows desktop, Windows files, screenshots, camera, notifications, browser proxy, or Windows commands, use the `nodes` tool (`status` / `describe`) and target the Windows node instead of assuming the WSL shell can do it. -For Windows shell work, use `exec host=node` / `system.run`; normal gateway exec runs in WSL. If Windows node commands fail, ask the user to check the tray Permissions page: Node mode, System run (or the requested capability), and Exec policy. If settings changed or capabilities look stale, ask the user to reconnect/restart the Windows node or gateway. +For Windows shell work, use `exec host=node` / `system.run`; normal gateway exec runs in WSL. Before claiming that camera, browser proxy, or system.run is unavailable, inspect the Windows Hub capability state with local MCP `app.connection.status` when available. Its `capabilities` entries distinguish the Settings toggle, Windows permission, Gateway declaration, pairing/approval, local MCP exposure, and runtime readiness, and provide the repair path. If the local MCP diagnostic is unavailable, ask the user to copy Capability diagnostics from Windows Hub Command Center. Do not infer camera permission from the Settings toggle alone. """; public static string ManagedBlock => $"{BeginMarker}\n{Payload.TrimEnd()}\n{EndMarker}"; diff --git a/src/OpenClaw.Shared/CapabilityTruthProjection.cs b/src/OpenClaw.Shared/CapabilityTruthProjection.cs new file mode 100644 index 000000000..196ed0ed6 --- /dev/null +++ b/src/OpenClaw.Shared/CapabilityTruthProjection.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared; + +/// +/// Pure, transport-neutral projection of the facts that determine whether an +/// agent can use a Windows-node capability. The owners of settings, Windows +/// permissions, gateway lifecycle, approval, and runtime probes remain +/// unchanged; this type only makes their combined result inspectable. +/// +public static class CapabilityTruthProjection +{ + /// + /// The declaration gate shared with the projection. Runtime and approval + /// facts may block usability, but they never override an explicit local + /// Settings opt-out by advertising the command anyway. + /// + public static bool ShouldDeclare(bool settingsEnabled) => settingsEnabled; + + public enum WindowsPermissionKind + { + NotRequired, + Unknown, + Allowed, + Denied, + } + + public enum ApprovalKind + { + NotConnected, + Unknown, + Approved, + Pending, + Rejected, + } + + public enum RuntimeKind + { + Unknown, + Ready, + Blocked, + } + + public sealed record Input( + string Id, + string DisplayName, + IReadOnlyList Commands, + bool SettingsEnabled, + WindowsPermissionKind WindowsPermission, + bool GatewaySessionLive, + ApprovalKind Approval, + IReadOnlyCollection EffectiveCommands, + IReadOnlyCollection PendingCommands, + IReadOnlyDictionary GatewayPermissions, + bool LocalMcpExposed, + RuntimeKind Runtime, + string? RuntimeDetail = null, + string? RuntimeRepair = null); + + public sealed record State( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("displayName")] string DisplayName, + [property: JsonPropertyName("commands")] IReadOnlyList Commands, + [property: JsonPropertyName("settingsEnabled")] bool SettingsEnabled, + [property: JsonPropertyName("windowsPermission")] string WindowsPermission, + [property: JsonPropertyName("gatewayDeclaration")] string GatewayDeclaration, + [property: JsonPropertyName("approval")] string Approval, + [property: JsonPropertyName("gatewayPathState")] string GatewayPathState, + [property: JsonPropertyName("gatewayRepair")] string GatewayRepair, + [property: JsonPropertyName("localMcpExposed")] bool LocalMcpExposed, + [property: JsonPropertyName("runtimeReadiness")] string RuntimeReadiness, + [property: JsonPropertyName("overallState")] string OverallState, + [property: JsonPropertyName("summary")] string Summary, + [property: JsonPropertyName("repair")] string Repair); + + public static State Project(Input input) + { + ArgumentNullException.ThrowIfNull(input); + + var gatewayEffective = input.GatewaySessionLive && input.Commands.All(command => + input.EffectiveCommands.Contains(command, StringComparer.OrdinalIgnoreCase)); + var pending = input.Commands.Any(command => + input.PendingCommands.Contains(command, StringComparer.OrdinalIgnoreCase)); + var gatewayPermissionBlocked = input.Commands.Any(command => + CommandCenterDiagnostics.TryGetCommandPermission(input.GatewayPermissions, command, out var allowed) && !allowed); + + var declaration = gatewayEffective + ? "effective" + : pending + ? "pending-approval" + : !input.GatewaySessionLive + ? "not-connected" + : "not-declared"; + + if (!input.SettingsEnabled) + { + return Build(input, declaration, "disabled", + $"{input.DisplayName} is disabled in Windows Hub Settings.", + $"Enable {input.DisplayName} on the Permissions page, save, and reconnect the Windows node."); + } + + if (input.WindowsPermission == WindowsPermissionKind.Denied) + { + return Build(input, declaration, "blocked", + $"{input.DisplayName} is enabled, but Windows permission is denied.", + input.RuntimeRepair ?? "Open Windows privacy settings, grant the required desktop-app permission, and retry."); + } + + if (input.Runtime == RuntimeKind.Blocked) + { + return Build(input, declaration, "blocked", + input.RuntimeDetail ?? $"{input.DisplayName} is not runtime-ready.", + input.RuntimeRepair ?? "Repair the runtime prerequisite and retry."); + } + + if (input.LocalMcpExposed) + { + var runtime = input.Runtime == RuntimeKind.Unknown ? "unchecked" : "ready"; + var summary = input.RuntimeDetail ?? + $"{input.DisplayName} is available through local MCP independently of the Gateway declaration."; + var repair = input.Runtime == RuntimeKind.Unknown + ? input.RuntimeRepair ?? "Invoke a read-only or safe command to verify the Windows runtime prerequisite." + : "No repair is required."; + return Build(input, declaration, "ready", summary, repair, runtime); + } + + if (!gatewayEffective && (pending || input.Approval == ApprovalKind.Rejected)) + { + return Build(input, declaration, "pending-approval", + $"{input.DisplayName} is waiting for gateway node command approval.", + "Approve the pending Windows node declaration on the Gateway, then reconnect the node."); + } + + if (gatewayPermissionBlocked) + { + return Build(input, declaration, "blocked", + $"{input.DisplayName} is declared, but the Gateway policy blocks one or more commands.", + "Review gateway.nodes allow/deny policy for the listed commands, approve the change, and reconnect the node."); + } + + if (gatewayEffective) + { + var runtime = input.Runtime == RuntimeKind.Unknown ? "unchecked" : "ready"; + var summary = input.RuntimeDetail ?? + $"{input.DisplayName} is effective for the connected Gateway node."; + var repair = input.Runtime == RuntimeKind.Unknown + ? input.RuntimeRepair ?? "Invoke a read-only or safe command to verify the Windows runtime prerequisite." + : "No repair is required."; + return Build(input, declaration, "ready", summary, repair, runtime); + } + + if (input.GatewaySessionLive) + { + return Build(input, declaration, "blocked", + $"{input.DisplayName} is enabled locally but is not in the Gateway's effective Windows node declaration.", + "Reconnect the Windows node. If the declaration changed, approve the pending command set on the Gateway."); + } + + return Build(input, declaration, "unavailable", + $"{input.DisplayName} is enabled, but neither local MCP nor a Gateway declaration currently exposes it.", + "Enable Local MCP Server or connect and approve the Windows node, then retry."); + } + + private static State Build( + Input input, + string declaration, + string overall, + string summary, + string repair, + string? runtime = null) + { + var gatewayEffective = input.GatewaySessionLive && input.Commands.All(command => + input.EffectiveCommands.Contains(command, StringComparer.OrdinalIgnoreCase)); + var pending = input.Commands.Any(command => + input.PendingCommands.Contains(command, StringComparer.OrdinalIgnoreCase)); + var gatewayPermissionBlocked = input.Commands.Any(command => + CommandCenterDiagnostics.TryGetCommandPermission(input.GatewayPermissions, command, out var allowed) && !allowed); + var gatewayPathState = !input.GatewaySessionLive + ? pending ? "pending-approval" : "not-connected" + : gatewayPermissionBlocked + ? "blocked" + : gatewayEffective + ? "ready" + : pending + ? "pending-approval" + : "not-declared"; + var gatewayRepair = gatewayPathState switch + { + "ready" when pending => "The current declaration remains effective. Approve the pending Windows node changes, then reconnect to activate them.", + "ready" => "No Gateway repair is required.", + "blocked" => "Review gateway.nodes allow/deny policy for the listed commands, approve the change, and reconnect the node.", + "pending-approval" => "Approve the pending Windows node declaration on the Gateway, then reconnect the node.", + "not-declared" => "Reconnect the Windows node. If the declaration changed, approve the pending command set on the Gateway.", + _ => "Connect the Windows node to inspect its Gateway declaration." + }; + + return new( + input.Id, + input.DisplayName, + input.Commands, + input.SettingsEnabled, + ToKebabCase(input.WindowsPermission.ToString()), + declaration, + ToKebabCase(input.Approval.ToString()), + gatewayPathState, + gatewayRepair, + input.LocalMcpExposed, + runtime ?? ToKebabCase(input.Runtime.ToString()), + overall, + summary, + repair); + } + + private static string ToKebabCase(string value) => + string.Concat(value.Select((character, index) => + index > 0 && char.IsUpper(character) ? $"-{char.ToLowerInvariant(character)}" : char.ToLowerInvariant(character).ToString())); +} diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index d17753081..612da83f7 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -386,7 +386,7 @@ private object HandleToolsList() ["app.chat.queue.cancel"] = "Cancel/remove one native chat outgoing queue entry before it is sent. Args: queuedMessageId (string, required), threadId/sessionKey (string, required; use the threadId returned by app.chat.queue.list or app.chat.snapshot). Only Queued/Failed entries can be removed; Sending entries may already have reached the gateway. Returns { canceled, threadId, queuedMessageId, remainingCount, error? }.", ["app.connection.status"] = - "READ-ONLY local MCP connection diagnostics. No args. Returns effective mode/state, active gateway metadata, operator/node credential resolution, MCP runtime state, browser proxy caveat, pending approval actions, retry hints, and recent diagnostic events.", + "READ-ONLY local MCP connection and capability diagnostics. No args. Returns effective mode/state, active gateway metadata, operator/node credential resolution, MCP runtime state, capability truth for camera/browser proxy/system.run, browser proxy caveat, pending approval actions, retry hints, and recent diagnostic events.", ["app.connection.gateways"] = "READ-ONLY saved gateway diagnostics. No args. Returns { activeGatewayId, count, gateways[] } with per-gateway id/name/url, active flag, lastConnected, credential presence booleans, SSH/browser-proxy configuration, and no token values.", ["app.connection.applySetupCode"] = diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index e006151aa..20a6da395 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1078,6 +1078,7 @@ public class GatewayCommandCenterState public GatewaySelfInfo? GatewaySelf { get; set; } public List PortDiagnostics { get; set; } = new(); public List Permissions { get; set; } = new(); + public List CapabilityStates { get; set; } = new(); public List Channels { get; set; } = new(); public List Sessions { get; set; } = new(); public GatewayUsageInfo? Usage { get; set; } diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index b59b91c0a..4e742444a 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -260,6 +260,14 @@ private void WireAppCapabilityHandlers() _nodeService?.McpStartupError); var diagnostics = _connectionManager?.Diagnostics; var recentDiagnostics = diagnostics?.GetRecent(50) ?? []; + var localNode = NodeCapabilityGating.GetLocalNodeInfo( + _appState?.Nodes, + _nodeService?.FullDeviceId); + var capabilityStates = NodeCapabilityTruthSource.Build( + _settings, + _nodeService, + localNode, + _connectionManager?.CurrentSnapshot.NodeState ?? RoleConnectionState.Idle); return Task.FromResult(ConnectionDiagnosticsProjection.BuildStatus( _connectionManager?.CurrentSnapshot, _gatewayRegistry?.GetActive(), @@ -269,7 +277,8 @@ private void WireAppCapabilityHandlers() mcpError: mcpPlan.ShouldShow ? mcpPlan.Message : null, nodeBrowserProxyEnabled: _settings?.NodeBrowserProxyEnabled != false, recentDiagnostics: recentDiagnostics, - diagnosticEventCount: diagnostics?.Count ?? recentDiagnostics.Count)); + diagnosticEventCount: diagnostics?.Count ?? recentDiagnostics.Count, + capabilities: capabilityStates)); }; connection.GatewaysHandler = () => diff --git a/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs index a26645c35..dbd60974a 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs @@ -194,6 +194,19 @@ internal static string BuildCapabilityDiagnosticsSummary(GatewayCommandCenterSta var builder = new StringBuilder(); builder.AppendLine("OpenClaw capability diagnostics"); builder.AppendLine($"Generated: {DateTimeOffset.Now:O}"); + builder.AppendLine(); + builder.AppendLine("Authoritative capability state:"); + foreach (var capability in state.CapabilityStates.OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)) + { + builder.AppendLine($"- {capability.DisplayName}: {capability.OverallState}"); + builder.AppendLine($" settings={capability.SettingsEnabled.ToString().ToLowerInvariant()}, windowsPermission={capability.WindowsPermission}, gatewayDeclaration={capability.GatewayDeclaration}, approval={capability.Approval}, gatewayPath={capability.GatewayPathState}, localMcp={capability.LocalMcpExposed.ToString().ToLowerInvariant()}, runtime={capability.RuntimeReadiness}"); + builder.AppendLine($" {capability.Summary}"); + if (!string.Equals(capability.GatewayPathState, "ready", StringComparison.OrdinalIgnoreCase)) + builder.AppendLine($" gateway repair: {capability.GatewayRepair}"); + if (!string.Equals(capability.OverallState, "ready", StringComparison.OrdinalIgnoreCase)) + builder.AppendLine($" repair: {capability.Repair}"); + } + builder.AppendLine(); builder.AppendLine("Windows permission surfaces:"); foreach (var permission in state.Permissions.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)) diff --git a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs index 4eba1d976..7cbf4021a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs @@ -24,6 +24,14 @@ internal GatewayCommandCenterState Build() { nodes.Add(NodeCapabilityHealthInfo.FromLocalDeclarations(localNode)); } + var gatewayLocalNode = NodeCapabilityGating.GetLocalNodeInfo( + _snapshot.Nodes, + _snapshot.NodeService?.FullDeviceId); + var capabilityStates = NodeCapabilityTruthSource.Build( + _snapshot.Settings, + _snapshot.NodeService, + gatewayLocalNode, + _snapshot.NodeConnectionState); var tunnelInputs = CommandCenterTopologyTunnelResolver.Derive( _snapshot.HasActiveGatewayRecord, @@ -248,6 +256,7 @@ node.ApprovalState is GatewayNodeApprovalState.PendingApproval or GatewaySelf = _snapshot.GatewaySelf, PortDiagnostics = portDiagnostics, Permissions = PermissionDiagnostics.BuildDefaultWindowsMatrix(), + CapabilityStates = capabilityStates.ToList(), Channels = _snapshot.Channels.Select(ChannelCommandCenterInfo.FromHealth).ToList(), Sessions = _snapshot.Sessions.ToList(), Usage = _snapshot.Usage, diff --git a/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs b/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs index afa1d3366..6b7e82cd3 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs @@ -18,7 +18,8 @@ internal static ConnectionStatusDiagnostics BuildStatus( string? mcpError, bool nodeBrowserProxyEnabled, IReadOnlyList recentDiagnostics, - int diagnosticEventCount) + int diagnosticEventCount, + IReadOnlyList? capabilities = null) { var snapshot = currentSnapshot ?? GatewayConnectionSnapshot.Idle; var legacyStatus = ConnectionStatusPresenter.ToLegacyStatus(snapshot); @@ -30,7 +31,7 @@ internal static ConnectionStatusDiagnostics BuildStatus( var nodeSessionLive = BrowserProxyActivation.IsNodeSessionLive(snapshot.NodeState); return new ConnectionStatusDiagnostics( - SchemaVersion: 1, + SchemaVersion: 2, ConnectionState: snapshot.OverallState.ToString(), EffectiveMode: GetEffectiveMode(enableNodeMode, enableMcpServer), LegacyConnectionStatus: legacyStatus.ToString(), @@ -70,6 +71,7 @@ internal static ConnectionStatusDiagnostics BuildStatus( Enabled: enableMcpServer, Running: isMcpRunning, Error: mcpError), + Capabilities: capabilities ?? [], BrowserProxy: BuildBrowserProxy(activeGateway, nodeBrowserProxyEnabled, nodeSessionLive), PendingActions: pendingActions, Retry: BuildRetry(recentDiagnostics), @@ -303,6 +305,7 @@ internal sealed record ConnectionStatusDiagnostics( [property: JsonPropertyName("operator")] OperatorConnectionDiagnostics Operator, [property: JsonPropertyName("node")] NodeConnectionDiagnostics Node, [property: JsonPropertyName("mcp")] McpConnectionDiagnostics Mcp, + [property: JsonPropertyName("capabilities")] IReadOnlyList Capabilities, [property: JsonPropertyName("browserProxy")] BrowserProxyDiagnostics BrowserProxy, [property: JsonPropertyName("pendingActions")] IReadOnlyList PendingActions, [property: JsonPropertyName("retry")] RetryDiagnostics Retry, diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs index 1839cea89..5a11b6fd6 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs @@ -23,9 +23,11 @@ internal static class NodeCapabilityGating { public static bool ShouldRegisterCanvas(SettingsManager? s) => s?.NodeCanvasEnabled != false; public static bool ShouldRegisterScreen(SettingsManager? s) => s?.NodeScreenEnabled != false; - public static bool ShouldRegisterCamera(SettingsManager? s) => s?.NodeCameraEnabled != false; + public static bool ShouldRegisterCamera(SettingsManager? s) => + OpenClaw.Shared.CapabilityTruthProjection.ShouldDeclare(s?.NodeCameraEnabled != false); public static bool ShouldRegisterLocation(SettingsManager? s) => s?.NodeLocationEnabled != false; - public static bool ShouldRegisterBrowserProxy(SettingsManager? s) => s?.NodeBrowserProxyEnabled != false; + public static bool ShouldRegisterBrowserProxy(SettingsManager? s) => + OpenClaw.Shared.CapabilityTruthProjection.ShouldDeclare(s?.NodeBrowserProxyEnabled != false); public static bool ShouldRegisterBrowserProxy( SettingsManager? s, string? sharedGatewayToken, @@ -77,7 +79,8 @@ public static BrowserProxyActivation.RegistrationBlock ResolveBrowserProxyRegist string.Equals(n.NodeId, localDeviceId, System.StringComparison.OrdinalIgnoreCase)); } - public static bool ShouldRegisterSystemRun(SettingsManager? s) => s?.NodeSystemRunEnabled != false; + public static bool ShouldRegisterSystemRun(SettingsManager? s) => + OpenClaw.Shared.CapabilityTruthProjection.ShouldDeclare(s?.NodeSystemRunEnabled != false); /// Counts node capability categories served by local MCP without a gateway node client. public static int CountMcpServedCapabilities(SettingsManager? s) diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityTruthSource.cs b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityTruthSource.cs new file mode 100644 index 000000000..190348a66 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityTruthSource.cs @@ -0,0 +1,113 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClawTray.Services; + +/// +/// Reads existing owners and supplies facts to . +/// It does not own settings, connection lifecycle, approvals, or runtime probes. +/// +internal static class NodeCapabilityTruthSource +{ + private static readonly string[] CameraCommands = ["camera.list", "camera.snap", "camera.clip"]; + private static readonly string[] BrowserCommands = ["browser.proxy"]; + private static readonly string[] SystemRunCommands = ["system.run", "system.run.prepare"]; + + public static IReadOnlyList Build( + SettingsManager? settings, + NodeService? nodeService, + GatewayNodeInfo? gatewayNode, + RoleConnectionState nodeConnectionState) + { + var localCommands = nodeService?.GetRegisteredCommands() ?? []; + var mcpRunning = nodeService?.IsMcpRunning == true; + var sessionLive = nodeConnectionState == RoleConnectionState.Connected; + var approval = ResolveApproval(sessionLive, gatewayNode?.ApprovalState); + var effective = gatewayNode?.Commands ?? []; + var pending = gatewayNode?.PendingDeclaredCommands ?? []; + var permissions = gatewayNode?.Permissions + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + var browserReadiness = nodeService?.BrowserProxyReadiness; + var browserRuntime = browserReadiness is { IsReady: true } + ? CapabilityTruthProjection.RuntimeKind.Ready + : browserReadiness is null + ? CapabilityTruthProjection.RuntimeKind.Unknown + : CapabilityTruthProjection.RuntimeKind.Blocked; + + return + [ + CapabilityTruthProjection.Project(new( + "camera", + "Camera", + CameraCommands, + settings?.NodeCameraEnabled != false, + nodeService?.CameraWindowsPermission ?? CapabilityTruthProjection.WindowsPermissionKind.Unknown, + sessionLive, + approval, + effective, + pending, + permissions, + mcpRunning && ContainsAll(localCommands, CameraCommands), + ContainsAll(localCommands, CameraCommands) + ? CapabilityTruthProjection.RuntimeKind.Unknown + : CapabilityTruthProjection.RuntimeKind.Blocked, + ContainsAll(localCommands, CameraCommands) + ? "Camera commands are registered. Windows camera permission and device availability are verified on invocation." + : "Camera commands are not registered in the current node runtime.", + "Open Windows Camera privacy settings, allow desktop apps, confirm a camera is present, then retry camera.list or camera.snap.")), + CapabilityTruthProjection.Project(new( + "browser-proxy", + "Browser proxy", + BrowserCommands, + settings?.NodeBrowserProxyEnabled != false, + CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + sessionLive, + approval, + effective, + pending, + permissions, + mcpRunning && ContainsAll(localCommands, BrowserCommands), + browserRuntime, + browserReadiness?.Summary ?? (ContainsAll(localCommands, BrowserCommands) + ? "Browser proxy is registered; host reachability and authentication are verified by its read-only preflight." + : "Browser proxy is not registered because a connection, trusted endpoint, or shared gateway token prerequisite is missing."), + browserReadiness?.Repair ?? "Check the browser readiness guidance in Command Center, repair the endpoint or shared-token prerequisite, then retry.")), + CapabilityTruthProjection.Project(new( + "system-run", + "System run", + SystemRunCommands, + settings?.NodeSystemRunEnabled != false, + CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + sessionLive, + approval, + effective, + pending, + permissions, + mcpRunning && ContainsAll(localCommands, SystemRunCommands), + ContainsAll(localCommands, SystemRunCommands) + ? CapabilityTruthProjection.RuntimeKind.Unknown + : CapabilityTruthProjection.RuntimeKind.Blocked, + ContainsAll(localCommands, SystemRunCommands) + ? "System run is registered and remains subject to local exec approvals and containment policy; runtime readiness is verified on execution." + : "System run commands are not registered in the current node runtime.", + "Enable System run, review the local Exec policy, save, and reconnect the Windows node.")) + ]; + } + + private static CapabilityTruthProjection.ApprovalKind ResolveApproval( + bool sessionLive, + GatewayNodeApprovalState? approval) => approval switch + { + GatewayNodeApprovalState.Approved => CapabilityTruthProjection.ApprovalKind.Approved, + GatewayNodeApprovalState.PendingApproval or GatewayNodeApprovalState.PendingReapproval => + CapabilityTruthProjection.ApprovalKind.Pending, + GatewayNodeApprovalState.Unapproved => CapabilityTruthProjection.ApprovalKind.Rejected, + _ => sessionLive + ? CapabilityTruthProjection.ApprovalKind.Unknown + : CapabilityTruthProjection.ApprovalKind.NotConnected, + }; + + private static bool ContainsAll(IReadOnlyCollection available, IReadOnlyList required) => + required.All(command => available.Contains(command, StringComparer.OrdinalIgnoreCase)); +} diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 7a85ebe8f..f6e09d4a2 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -161,6 +161,9 @@ public sealed class NodeService : IDisposable, IAsyncDisposable private McpToolBridge? _mcpToolBridge; private string? _mcpStartupError; public bool IsMcpRunning => _mcpServer != null; + public BrowserProxyReadiness.Result? BrowserProxyReadiness => _browserProxyCapability?.LastReadiness; + public CapabilityTruthProjection.WindowsPermissionKind CameraWindowsPermission { get; private set; } = + CapabilityTruthProjection.WindowsPermissionKind.Unknown; public VoiceService? VoiceService => _voiceService; public TextToSpeechService? TextToSpeech => _textToSpeechService; public string McpEndpoint => McpServerUrl; @@ -191,6 +194,18 @@ public sealed class NodeService : IDisposable, IAsyncDisposable public string? FullDeviceId => _nodeClient?.FullDeviceId; public string? GatewayUrl => _nodeClient?.GatewayUrl; + public IReadOnlyList GetRegisteredCommands() + { + lock (_capabilitiesLock) + { + return _capabilities + .SelectMany(capability => capability.Commands) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + /// Show the canvas window (creates it if needed). public void ShowCanvasWindow() { @@ -414,6 +429,7 @@ private void RegisterCapabilities() browserEndpointVerified: BrowserProxyActivation.IsSshBrowserEndpointVerified( activeGatewayTunnel, browserControlPort)); + _browserProxyCapability = null; if (browserProxyBlock == BrowserProxyActivation.RegistrationBlock.None) { // Tunnel state is resolved from the active GatewayRecord when a resolver is wired @@ -1965,14 +1981,24 @@ private async Task OnScreenRecord( #region Camera Capability Handlers - private Task OnCameraList(CancellationToken cancellationToken) + private async Task OnCameraList(CancellationToken cancellationToken) { if (_cameraCaptureService == null) { throw new InvalidOperationException("Camera capture service not available"); } - return _cameraCaptureService.ListCamerasAsync(cancellationToken); + try + { + var cameras = await _cameraCaptureService.ListCamerasAsync(cancellationToken); + CameraWindowsPermission = CapabilityTruthProjection.WindowsPermissionKind.Unknown; + return cameras; + } + catch (UnauthorizedAccessException) + { + CameraWindowsPermission = CapabilityTruthProjection.WindowsPermissionKind.Denied; + throw; + } } private async Task OnCameraSnap( @@ -1986,10 +2012,13 @@ private async Task OnCameraSnap( try { - return await _cameraCaptureService.SnapAsync(args, cancellationToken); + var result = await _cameraCaptureService.SnapAsync(args, cancellationToken); + CameraWindowsPermission = CapabilityTruthProjection.WindowsPermissionKind.Allowed; + return result; } catch (UnauthorizedAccessException ex) { + CameraWindowsPermission = CapabilityTruthProjection.WindowsPermissionKind.Denied; RequestNodeToast( LocalizationHelper.GetString("Toast_CameraBlocked"), LocalizationHelper.GetString("Toast_CameraBlockedDetail"), @@ -2023,6 +2052,7 @@ private async Task OnCameraClip( LocalizationHelper.GetString("Toast_CameraRecordingStartedDetail"), "node:camera-recording-started"); var result = await _cameraCaptureService.ClipAsync(args, cancellationToken); + CameraWindowsPermission = CapabilityTruthProjection.WindowsPermissionKind.Allowed; cancellationToken.ThrowIfCancellationRequested(); RequestNodeToast( LocalizationHelper.GetString("Toast_CameraRecordingComplete"), @@ -2033,6 +2063,7 @@ private async Task OnCameraClip( } catch (UnauthorizedAccessException ex) { + CameraWindowsPermission = CapabilityTruthProjection.WindowsPermissionKind.Denied; RequestNodeToast( LocalizationHelper.GetString("Toast_CameraBlocked"), LocalizationHelper.GetString("Toast_CameraBlockedDetail"), diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md index 95329190b..fa536d380 100644 --- a/src/OpenClaw.WinNode.Cli/skill.md +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -429,7 +429,7 @@ presence/outcome only. ### app.connection.status Read-only connection diagnostics for agents and CLIs. No params. Returns: -`{ schemaVersion, connectionState, effectiveMode, legacyConnectionStatus, gateway, operator, node, mcp, browserProxy, pendingActions, retry, diagnostics }`. +`{ schemaVersion, connectionState, effectiveMode, legacyConnectionStatus, gateway, operator, node, mcp, capabilities, browserProxy, pendingActions, retry, diagnostics }`. The payload includes the active gateway id/name/url, operator and node role states, credential sources/statuses, MCP enabled/running/error state, browser @@ -438,6 +438,12 @@ recent diagnostics, and recent connection diagnostic events. `effectiveMode` reflects Settings mode (`EnableNodeMode` / `EnableMcpServer`); `node.intended` reflects the manager snapshot plus current Node mode setting. +`capabilities` is the authoritative projection for `camera`, `browser-proxy`, +and `system-run`. Each entry reports the Settings toggle, Windows permission, +effective or pending Gateway declaration, approval state, local MCP exposure, +Gateway-path state/repair, runtime readiness, overall state, and a repair path. Treat `ready` with runtime +`unchecked` as discoverable but still requiring a safe live invocation. + ### app.connection.gateways Read-only saved gateway diagnostics. No params. Returns: `{ activeGatewayId, count, gateways[] }`. diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index 9c3de8de8..9af4889d6 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -572,6 +572,8 @@ public void WindowsNodeContextSection_ManagedBlock_ContainsMarkersAndPayload() Assert.StartsWith(WindowsNodeContextSection.BeginMarker + "\n", block); Assert.Contains("This WSL gateway may be paired", block); Assert.Contains("exec host=node", block); + Assert.Contains("app.connection.status", block); + Assert.Contains("Do not infer camera permission", block); Assert.DoesNotContain("tools.exec.security full", block); Assert.DoesNotContain("tools.exec.ask off", block); Assert.EndsWith("\n" + WindowsNodeContextSection.EndMarker, block); diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTruthProjectionTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTruthProjectionTests.cs new file mode 100644 index 000000000..3ce51bb39 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/CapabilityTruthProjectionTests.cs @@ -0,0 +1,228 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public sealed class CapabilityTruthProjectionTests +{ + [Fact] + public void Project_CameraEnabledButPermissionDenied_IsBlockedWithRepair() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.Denied, + effective: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Approved, + runtime: CapabilityTruthProjection.RuntimeKind.Ready)); + + Assert.Equal("blocked", state.OverallState); + Assert.Equal("denied", state.WindowsPermission); + Assert.Contains("privacy", state.Repair, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Project_PendingGatewayDeclaration_IsNotReportedReady() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.Unknown, + effective: [], + pending: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Pending, + runtime: CapabilityTruthProjection.RuntimeKind.Unknown)); + + Assert.Equal("pending-approval", state.OverallState); + Assert.Equal("pending-approval", state.GatewayDeclaration); + Assert.Contains("Approve", state.Repair, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Project_PendingDeclaration_RemainsVisibleBeforeSessionConnects() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.Unknown, + sessionLive: false, + effective: [], + pending: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Pending, + runtime: CapabilityTruthProjection.RuntimeKind.Unknown)); + + Assert.Equal("pending-approval", state.GatewayDeclaration); + Assert.Equal("pending-approval", state.Approval); + Assert.Equal("pending-approval", state.OverallState); + } + + [Fact] + public void Project_LocalMcpOnly_IsReadyButGatewayTruthRemainsNotConnected() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + sessionLive: false, + effective: [], + approval: CapabilityTruthProjection.ApprovalKind.NotConnected, + localMcp: true, + runtime: CapabilityTruthProjection.RuntimeKind.Ready)); + + Assert.Equal("ready", state.OverallState); + Assert.Equal("not-connected", state.GatewayDeclaration); + Assert.True(state.LocalMcpExposed); + Assert.Contains("local MCP", state.Summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Project_LocalMcpReady_WinsOverMissingGatewayDeclaration() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + sessionLive: true, + effective: [], + approval: CapabilityTruthProjection.ApprovalKind.Approved, + localMcp: true, + runtime: CapabilityTruthProjection.RuntimeKind.Ready)); + + Assert.Equal("ready", state.OverallState); + Assert.Equal("not-declared", state.GatewayDeclaration); + Assert.True(state.LocalMcpExposed); + } + + [Fact] + public void Project_LocalMcpReady_WinsOverPendingGatewayApproval() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + sessionLive: true, + effective: [], + pending: ["camera.list"], + approval: CapabilityTruthProjection.ApprovalKind.Pending, + localMcp: true, + runtime: CapabilityTruthProjection.RuntimeKind.Ready)); + + Assert.Equal("ready", state.OverallState); + Assert.Equal("pending-approval", state.GatewayDeclaration); + Assert.Equal("pending", state.Approval); + Assert.Equal("pending-approval", state.GatewayPathState); + } + + [Fact] + public void Project_DisconnectedRetainedGatewayCommands_AreNotReady() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + sessionLive: false, + effective: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.NotConnected, + runtime: CapabilityTruthProjection.RuntimeKind.Ready)); + + Assert.Equal("unavailable", state.OverallState); + Assert.Equal("not-connected", state.GatewayDeclaration); + } + + [Fact] + public void Project_GatewayPolicyBlock_WinsOverRegisteredRuntime() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + effective: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Approved, + runtime: CapabilityTruthProjection.RuntimeKind.Ready, + permissions: new Dictionary { ["camera.snap"] = false })); + + Assert.Equal("blocked", state.OverallState); + Assert.Contains("Gateway policy", state.Summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Project_LocalMcpReady_PreservesGatewayPolicyBlockDetails() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + effective: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Approved, + localMcp: true, + runtime: CapabilityTruthProjection.RuntimeKind.Ready, + permissions: new Dictionary { ["camera.snap"] = false })); + + Assert.Equal("ready", state.OverallState); + Assert.Equal("blocked", state.GatewayPathState); + Assert.Contains("allow/deny", state.GatewayRepair, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Project_UnrelatedPendingReapproval_DoesNotBlockEffectiveCapability() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + effective: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Pending, + runtime: CapabilityTruthProjection.RuntimeKind.Ready)); + + Assert.Equal("ready", state.OverallState); + Assert.Equal("ready", state.GatewayPathState); + } + + [Fact] + public void Project_EffectiveCommandsRemainReadyDuringMatchingPendingReapproval() + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + effective: ["camera.list", "camera.snap", "camera.clip"], + pending: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Pending, + runtime: CapabilityTruthProjection.RuntimeKind.Ready)); + + Assert.Equal("ready", state.OverallState); + Assert.Equal("effective", state.GatewayDeclaration); + Assert.Equal("pending", state.Approval); + Assert.Equal("ready", state.GatewayPathState); + Assert.Contains("pending", state.GatewayRepair, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("commands.camera.snap")] + [InlineData("command:camera.snap")] + public void Project_PrefixedGatewayPolicyBlock_IsDetected(string permissionKey) + { + var state = CapabilityTruthProjection.Project(Input( + settingsEnabled: true, + permission: CapabilityTruthProjection.WindowsPermissionKind.NotRequired, + effective: ["camera.list", "camera.snap", "camera.clip"], + approval: CapabilityTruthProjection.ApprovalKind.Approved, + runtime: CapabilityTruthProjection.RuntimeKind.Ready, + permissions: new Dictionary { [permissionKey] = false })); + + Assert.Equal("blocked", state.OverallState); + Assert.Equal("blocked", state.GatewayPathState); + } + + private static CapabilityTruthProjection.Input Input( + bool settingsEnabled, + CapabilityTruthProjection.WindowsPermissionKind permission, + IReadOnlyCollection effective, + CapabilityTruthProjection.ApprovalKind approval, + CapabilityTruthProjection.RuntimeKind runtime, + IReadOnlyCollection? pending = null, + bool sessionLive = true, + bool localMcp = false, + IReadOnlyDictionary? permissions = null) => new( + "camera", + "Camera", + ["camera.list", "camera.snap", "camera.clip"], + settingsEnabled, + permission, + sessionLive, + approval, + effective, + pending ?? [], + permissions ?? new Dictionary(), + localMcp, + runtime, + RuntimeRepair: "Open Windows privacy settings and retry."); +} diff --git a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs index ab344f9b8..d0cab8e79 100644 --- a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs +++ b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs @@ -214,6 +214,54 @@ public async Task ToolsCall_DispatchesToCapability_AndReturnsTextContent() Assert.Equal(42, payload.RootElement.GetProperty("n").GetInt32()); } + [Fact] + public async Task ConnectionStatus_ToolsListAndCall_ExposeCapabilityTruth() + { + var capability = new AppConnectionCapability(NullLogger.Instance) + { + StatusHandler = () => Task.FromResult(new + { + schemaVersion = 2, + capabilities = new[] + { + CapabilityTruthProjection.Project(new( + "camera", + "Camera", + ["camera.list", "camera.snap", "camera.clip"], + true, + CapabilityTruthProjection.WindowsPermissionKind.Unknown, + false, + CapabilityTruthProjection.ApprovalKind.NotConnected, + [], + [], + new Dictionary(), + true, + CapabilityTruthProjection.RuntimeKind.Unknown)) + } + }) + }; + var bridge = CreateBridge([capability]); + + var listResponse = await bridge.HandleRequestAsync( + """{"jsonrpc":"2.0","id":1,"method":"tools/list"}"""); + using (var list = JsonDocument.Parse(listResponse!)) + { + Assert.Contains( + list.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray(), + tool => tool.GetProperty("name").GetString() == "app.connection.status"); + } + + var callResponse = await bridge.HandleRequestAsync( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"app.connection.status","arguments":{}}}"""); + using var call = JsonDocument.Parse(callResponse!); + var text = call.RootElement.GetProperty("result").GetProperty("content")[0].GetProperty("text").GetString()!; + using var payload = JsonDocument.Parse(text); + var camera = payload.RootElement.GetProperty("capabilities")[0]; + Assert.Equal("camera", camera.GetProperty("id").GetString()); + Assert.Equal("unknown", camera.GetProperty("windowsPermission").GetString()); + Assert.Equal("not-connected", camera.GetProperty("gatewayDeclaration").GetString()); + } + [Fact] public async Task ToolsCall_UnknownTool_ReturnsToolErrorNotJsonRpcError() { diff --git a/tests/OpenClaw.Tray.Tests/CommandCenterStateBuilderTests.cs b/tests/OpenClaw.Tray.Tests/CommandCenterStateBuilderTests.cs index aeb7589c8..037214c5e 100644 --- a/tests/OpenClaw.Tray.Tests/CommandCenterStateBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/CommandCenterStateBuilderTests.cs @@ -6,6 +6,21 @@ namespace OpenClaw.Tray.Tests; public sealed class CommandCenterStateBuilderTests { + [Fact] + public void CapabilityTruth_IsSharedByCommandCenterAndAgentFacingStatus() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var builder = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "CommandCenterStateBuilder.cs")); + var handlers = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "App.CapabilityHandlers.cs")); + + Assert.Contains("NodeCapabilityTruthSource.Build(", builder, StringComparison.Ordinal); + Assert.Contains("CapabilityStates = capabilityStates.ToList()", builder, StringComparison.Ordinal); + Assert.Contains("NodeCapabilityTruthSource.Build(", handlers, StringComparison.Ordinal); + Assert.Contains("capabilities: capabilityStates", handlers, StringComparison.Ordinal); + } + [Fact] public void BrowserProxyAuthWarning_ShowsOnlyWhenNodeSessionLiveAndSharedTokenMissing() { diff --git a/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs index 4e22269a5..240c334af 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs @@ -6,6 +6,39 @@ namespace OpenClaw.Tray.Tests; public sealed class ConnectionDiagnosticsProjectionTests { + [Fact] + public void BuildStatus_IncludesAuthoritativeCapabilityState() + { + var camera = CapabilityTruthProjection.Project(new( + "camera", + "Camera", + ["camera.list", "camera.snap", "camera.clip"], + true, + CapabilityTruthProjection.WindowsPermissionKind.Unknown, + false, + CapabilityTruthProjection.ApprovalKind.NotConnected, + [], + [], + new Dictionary(), + true, + CapabilityTruthProjection.RuntimeKind.Unknown)); + + var status = ConnectionDiagnosticsProjection.BuildStatus( + GatewayConnectionSnapshot.Idle, + activeGateway: null, + enableNodeMode: false, + enableMcpServer: true, + isMcpRunning: true, + mcpError: null, + nodeBrowserProxyEnabled: true, + recentDiagnostics: [], + diagnosticEventCount: 0, + capabilities: [camera]); + + Assert.Equal(2, status.SchemaVersion); + Assert.Equal(camera, Assert.Single(status.Capabilities)); + } + [Fact] public void BuildStatus_ExplainsActiveGatewayRolesCredentialsAndActions() {