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

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