From 268f4dd04174082fd9430402665c490b0a11f5e8 Mon Sep 17 00:00:00 2001 From: Regis Brid Date: Fri, 12 Jun 2026 14:41:07 -0700 Subject: [PATCH] Add inline exec approval flow Route local exec approval prompts into the active chat timeline when session context is available, including allow-once, always-allow, deny, resolved-state stamping, and local prompt cancellation handling. Preserve native prompt fallback for requests without chat context. Thread session keys through node.invoke request/event paths and system.run approvals while preserving trusted envelope metadata over command args. Keep legacy policy compatibility by accepting ask/prompt/numeric action values and preserving stable ChatPermissionDecision enum values. Update the permissions UI to use canonical prompt actions, upsert exact duplicate command patterns, safely coalesce duplicate loaded rules without changing first-match semantics, and refresh localized approval copy for allow-once versus always-allow behavior. Add regression coverage for inline approval decisions, session-key propagation, duplicate policy rule handling, legacy policy deserialization, enum stability, and action-button fallback behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Chat/ChatModels.cs | 46 ++++- src/OpenClaw.Chat/ChatTimelineReducer.cs | 5 +- .../Capabilities/SystemCapability.cs | 13 +- src/OpenClaw.Shared/ExecApprovalPolicy.cs | 21 +- src/OpenClaw.Shared/ExecApprovalPrompt.cs | 2 + src/OpenClaw.Shared/NodeCapabilities.cs | 1 + src/OpenClaw.Shared/WindowsNodeClient.cs | 31 ++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 +- .../Chat/Explorations/FakeChatDataProvider.cs | 1 + .../Chat/OpenClawChatDataProvider.cs | 181 ++++++++++++++++-- .../Chat/OpenClawChatRoot.cs | 6 +- .../Chat/OpenClawChatTimeline.cs | 75 ++++---- .../Pages/ExecPolicyRuleList.cs | 84 ++++++++ .../Pages/PermissionsPage.xaml | 7 +- .../Pages/PermissionsPage.xaml.cs | 49 +++-- .../Services/ExecApprovalPromptService.cs | 39 +++- .../Services/NodeService.cs | 6 +- .../Strings/en-us/Resources.resw | 27 ++- .../Strings/fr-fr/Resources.resw | 27 ++- .../Strings/nl-nl/Resources.resw | 27 ++- .../Strings/zh-cn/Resources.resw | 27 ++- .../Strings/zh-tw/Resources.resw | 27 ++- .../ExecApprovalPolicyTests.cs | 175 +++++++++-------- tests/OpenClaw.Shared.Tests/SystemRunTests.cs | 78 ++++++++ .../WindowsNodeClientTests.cs | 119 ++++++++++++ .../ChatTimelineReducerTests.cs | 18 ++ .../ExecPolicyRuleListTests.cs | 113 +++++++++++ .../OpenClaw.Tray.Tests.csproj | 1 + .../OpenClawChatDataProviderTests.cs | 83 +++++++- 29 files changed, 1086 insertions(+), 208 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs create mode 100644 tests/OpenClaw.Tray.Tests/ExecPolicyRuleListTests.cs diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index e0e90515d..fd26fdac9 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -45,10 +45,33 @@ public enum ChatTimelineItemKind /// public enum ChatPermissionDecision { - Pending, - Allowed, - Denied, - Expired + Pending = 0, + Allowed = 1, + Denied = 2, + Expired = 3, + AllowedAlways = 4 +} + +public static class ChatPermissionActionKeys +{ + public const string AllowOnce = "allow-once"; + public const string AllowAlways = "allow-always"; + public const string Deny = "deny"; + + public static readonly string[] ExecApprovalDefaults = [AllowOnce, AllowAlways, Deny]; + + public static string[] NormalizeActions(IReadOnlyList? actions) + { + if (actions is not { Count: > 0 }) + return ExecApprovalDefaults; + + var normalized = actions + .Where(action => !string.IsNullOrWhiteSpace(action)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return normalized.Length > 0 ? normalized : ExecApprovalDefaults; + } } public enum ChatToolCallStatus @@ -103,9 +126,10 @@ public record ChatTimelineItem( ChatTone? Tone = null, string? ToolCallId = null, string? PermissionRequestId = null, - ChatPermissionDecision PermissionDecision = ChatPermissionDecision.Pending); + ChatPermissionDecision PermissionDecision = ChatPermissionDecision.Pending, + IReadOnlyList? PermissionActions = null); -public record ChatPermissionRequest(string RequestId, string PermissionKind, string ToolName, string Detail); +public record ChatPermissionRequest(string RequestId, string PermissionKind, string ToolName, string Detail, IReadOnlyList? Actions = null); public record ChatTimelineState( System.Collections.Immutable.ImmutableList Entries, @@ -152,7 +176,7 @@ public record ChatContextChangedEvent(string? Cwd, string? GitBranch) : ChatEven public record ChatStatusEvent(string Text, ChatTone Tone) : ChatEvent; public record ChatErrorEvent(string Text) : ChatEvent; public record ChatRestoredEvent(string Text) : ChatEvent; -public record ChatPermissionRequestEvent(string RequestId, string PermissionKind, string ToolName, string Detail) : ChatEvent; +public record ChatPermissionRequestEvent(string RequestId, string PermissionKind, string ToolName, string Detail, IReadOnlyList? Actions = null) : ChatEvent; public record ChatModelChangedEvent(string Model) : ChatEvent; public record ChatRawEvent(string EventType, string? Text = null) : ChatEvent; @@ -231,5 +255,11 @@ Task SendMessageAsync(string threadId, string message, CancellationToken cancell Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default); Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default); Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default); - Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default); + Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default); + Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default) => + RespondToPermissionAsync( + threadId, + requestId, + allow ? ChatPermissionActionKeys.AllowOnce : ChatPermissionActionKeys.Deny, + cancellationToken); } diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs index 4963e73b4..94cca1789 100644 --- a/src/OpenClaw.Chat/ChatTimelineReducer.cs +++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs @@ -174,13 +174,14 @@ static ChatTimelineState ApplyPermissionRequest(ChatTimelineState state, ChatPer ToolName: e.ToolName, IntentSummary: e.PermissionKind, PermissionRequestId: e.RequestId, - PermissionDecision: ChatPermissionDecision.Pending); + PermissionDecision: ChatPermissionDecision.Pending, + PermissionActions: e.Actions); return state with { Entries = entries.Add(entry), NextId = state.NextId + 1, - PendingPermission = new ChatPermissionRequest(e.RequestId, e.PermissionKind, e.ToolName, detail) + PendingPermission = new ChatPermissionRequest(e.RequestId, e.PermissionKind, e.ToolName, detail, e.Actions) }; } diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index f18061d04..3b682d378 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -277,7 +277,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request) var rawCommand = GetStringArg(request.Args, "rawCommand"); var cwd = GetStringArg(request.Args, "cwd"); var agentId = GetStringArg(request.Args, "agentId"); - var sessionKey = GetStringArg(request.Args, "sessionKey"); + var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey"); Logger.Info($"system.run.prepare: {rawCommand} (cwd={cwd ?? "default"})"); @@ -357,6 +357,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) var shell = GetStringArg(request.Args, "shell"); var cwd = GetStringArg(request.Args, "cwd"); + var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey"); var timeoutMs = GetIntArg(request.Args, "timeoutMs", GetIntArg(request.Args, "timeout", DefaultRunTimeoutMs)); // Clamp caller-supplied timeouts. timeoutMs <= 0 historically meant @@ -406,7 +407,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) if (_approvalPolicy != null) { var approval = _approvalPolicy.Evaluate(fullCommand, shell); - var approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval); + var approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId); if (!approvalCheck.Allowed) { Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})"); @@ -437,7 +438,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) continue; } - var innerApprovalCheck = await EnsureApprovedAsync(target.Command, target.Shell, innerApproval); + var innerApprovalCheck = await EnsureApprovedAsync(target.Command, target.Shell, innerApproval, sessionKey, correlationId); if (!innerApprovalCheck.Allowed) { Logger.Warn($"system.run DENIED: {target.Command} ({innerApproval.Reason})"); @@ -478,6 +479,8 @@ private async Task EnsureApprovedAsync( string command, string? shell, ExecApprovalResult approval, + string? sessionKey, + string correlationId, CancellationToken cancellationToken = default) { if (approval.Allowed) @@ -494,7 +497,9 @@ private async Task EnsureApprovedAsync( Command = command, Shell = shell, MatchedPattern = approval.MatchedPattern, - Reason = approval.Reason ?? "Command requires approval" + Reason = approval.Reason ?? "Command requires approval", + SessionKey = sessionKey, + CorrelationId = correlationId }, cancellationToken); if (decision.Kind == ExecApprovalPromptDecisionKind.Deny) diff --git a/src/OpenClaw.Shared/ExecApprovalPolicy.cs b/src/OpenClaw.Shared/ExecApprovalPolicy.cs index 644008246..cbcbcf5f0 100644 --- a/src/OpenClaw.Shared/ExecApprovalPolicy.cs +++ b/src/OpenClaw.Shared/ExecApprovalPolicy.cs @@ -42,17 +42,28 @@ public enum ExecApprovalAction /// /// JsonConverter for that emits/accepts the canonical -/// camelCase values ("allow", "deny", "prompt") but also accepts the legacy "ask" alias. -/// Older builds of the Permissions UI wrote "ask" for the Prompt action; without this -/// converter, deserialization would throw and the entire policy file (including any -/// user-authored rules) would be silently replaced with the default policy on load. +/// camelCase values ("allow", "deny", "prompt") but also accepts legacy values written +/// by older builds: the "ask" alias and numeric enum values. /// internal sealed class ExecApprovalActionConverter : JsonConverter { public override ExecApprovalAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Number) + { + if (!reader.TryGetInt32(out var numericValue)) + throw new JsonException("Expected integer value for ExecApprovalAction"); + + return numericValue switch + { + (int)ExecApprovalAction.Allow => ExecApprovalAction.Allow, + (int)ExecApprovalAction.Deny => ExecApprovalAction.Deny, + (int)ExecApprovalAction.Prompt => ExecApprovalAction.Prompt, + _ => throw new JsonException($"Unknown ExecApprovalAction numeric value '{numericValue}'") + }; + } if (reader.TokenType != JsonTokenType.String) - throw new JsonException($"Expected string for ExecApprovalAction, got {reader.TokenType}"); + throw new JsonException($"Expected string or number for ExecApprovalAction, got {reader.TokenType}"); var value = reader.GetString(); return value?.ToLowerInvariant() switch diff --git a/src/OpenClaw.Shared/ExecApprovalPrompt.cs b/src/OpenClaw.Shared/ExecApprovalPrompt.cs index 622b31443..d090a5052 100644 --- a/src/OpenClaw.Shared/ExecApprovalPrompt.cs +++ b/src/OpenClaw.Shared/ExecApprovalPrompt.cs @@ -17,6 +17,8 @@ public sealed class ExecApprovalPromptRequest public string? Shell { get; init; } public string? MatchedPattern { get; init; } public string Reason { get; init; } = ""; + public string? SessionKey { get; init; } + public string? CorrelationId { get; init; } } public sealed class ExecApprovalPromptDecision diff --git a/src/OpenClaw.Shared/NodeCapabilities.cs b/src/OpenClaw.Shared/NodeCapabilities.cs index 1c3feed2a..12358e3cf 100644 --- a/src/OpenClaw.Shared/NodeCapabilities.cs +++ b/src/OpenClaw.Shared/NodeCapabilities.cs @@ -24,6 +24,7 @@ public class NodeInvokeRequest public string Id { get; set; } = ""; public string Command { get; set; } = ""; public JsonElement Args { get; set; } + public string? SessionKey { get; set; } } public class NodeInvokeCompletedEventArgs : EventArgs diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 52859f024..8ca7c8125 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -481,6 +481,8 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root) } } } + + var sessionKey = ExtractNodeInvokeSessionKey(payload, args); _logger.Info($"[NODE] Invoking command: {command}"); @@ -489,7 +491,8 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root) { Id = requestId, Command = command, - Args = args + Args = args, + SessionKey = sessionKey }; // Find capability that can handle this command @@ -1041,6 +1044,7 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId) var args = paramsEl.TryGetProperty("args", out var argsEl) ? argsEl.Clone() : default; + var sessionKey = ExtractNodeInvokeSessionKey(paramsEl, args); _logger.Info($"Received node.invoke: {command}"); @@ -1048,7 +1052,8 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId) { Id = requestId, Command = command, - Args = args + Args = args, + SessionKey = sessionKey }; // Find capability that can handle this command @@ -1109,6 +1114,28 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId) }, CancellationToken.None); } + private static string? ExtractNodeInvokeSessionKey(JsonElement envelope, JsonElement args) + { + if (envelope.TryGetProperty("sessionKey", out var envelopeSessionKey) && + envelopeSessionKey.ValueKind == JsonValueKind.String) + { + var sessionKey = envelopeSessionKey.GetString(); + if (!string.IsNullOrWhiteSpace(sessionKey)) + return sessionKey; + } + + if (args.ValueKind == JsonValueKind.Object && + args.TryGetProperty("sessionKey", out var argsSessionKey) && + argsSessionKey.ValueKind == JsonValueKind.String) + { + var sessionKey = argsSessionKey.GetString(); + if (!string.IsNullOrWhiteSpace(sessionKey)) + return sessionKey; + } + + return null; + } + private void RaiseInvokeCompleted(string requestId, string command, bool ok, string? error, TimeSpan duration) { InvokeCompleted?.Invoke(this, new NodeInvokeCompletedEventArgs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 3ca68543a..b72a848ca 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1756,8 +1756,9 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna new AppLogger(), _dispatcherQueue, DataPath, - () => _keepAliveWindow?.Content as FrameworkElement, - settings, + rootProvider: () => _keepAliveWindow?.Content as FrameworkElement, + chatProviderProvider: () => _chatCoordinator?.Provider, + settings: settings, enableMcpServer: settings.EnableMcpServer, identityDataPath: IdentityDataPath, sharedGatewayTokenResolver: () => _gatewayRegistry?.GetActive()?.SharedGatewayToken); diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs index 643d9a827..fbaa5ad75 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs @@ -122,6 +122,7 @@ public Task SendMessageAsync(string threadId, string message, CancellationToken public Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; } diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 9f9110e3c..5b2b71e3a 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -22,6 +22,8 @@ internal static class LocalizationHelper public static string GetString(string resourceKey) => resourceKey switch { "Chat_TruncationMarkerFormat" => " … [{0} bytes truncated]", + "Chat_Permission_CommandApprovalTitle" => "Command approval requested", + "Chat_Permission_ResultSubmittedFormat" => "Approval {0} submitted for {1}.", _ => resourceKey }; } @@ -82,6 +84,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes private long _toolMetaSaveVersion; private readonly Dictionary _timelines = new(); + private readonly Dictionary _localInlineApprovals = new(StringComparer.Ordinal); private readonly Dictionary _activeRunIds = new(); // sessionKey → runId private readonly Dictionary _pendingAbortCounts = new(); // threads → count of pending aborts waiting for lifecycle.start private readonly HashSet _abortedRunIds = new(); // runIds whose events should be suppressed @@ -126,6 +129,11 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private const int MaxHistoryRetries = 3; private static readonly TimeSpan LocalEchoSuppressionWindow = TimeSpan.FromSeconds(30); private readonly record struct LocalSentText(string Text, DateTimeOffset SentAt); + private sealed record LocalInlineApproval( + string ThreadId, + string RequestId, + string Detail, + TaskCompletionSource Response); // Per-thread, per-entry metadata: timestamp + model snapshot at the // moment the entry was created. Built up as events are applied so the // timeline renderer can show a " · · " footer @@ -910,12 +918,23 @@ public Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationT return Task.CompletedTask; } - public async Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default) + public Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default) => + RespondToPermissionAsync( + threadId, + requestId, + allow ? ChatPermissionActionKeys.AllowOnce : ChatPermissionActionKeys.Deny, + cancellationToken); + + public async Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrEmpty(threadId) || string.IsNullOrEmpty(requestId)) return; + var decision = NormalizeApprovalAction(action); + if (TryResolveLocalInlineApproval(threadId, requestId, decision)) + return; + // Use the operator-approvals gateway RPC (``exec.approval.resolve``) // rather than the ``/approve `` chat slash command. // @@ -925,8 +944,6 @@ public async Task RespondToPermissionAsync(string threadId, string requestId, bo // sits in the input queue until the run times out, by which point the // approval has already expired and the approve/deny is a no-op. The // RPC bypasses the chat queue and resolves the approval immediately. - var decision = allow ? "allow-once" : "deny"; - Logger.Info($"[Approval] user response requestId={requestId} decision={decision} thread='{threadId}'"); try @@ -944,9 +961,116 @@ public async Task RespondToPermissionAsync(string threadId, string requestId, bo } ClearPendingPermissionAndPublish(threadId, expectedRequestId: requestId, - decision: allow ? ChatPermissionDecision.Allowed : ChatPermissionDecision.Denied); + decision: ChatDecisionForApprovalAction(decision)); + } + + internal async Task RequestLocalExecApprovalAsync( + ExecApprovalPromptRequest request, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(request.SessionKey) || _disposed) + return null; + + var threadId = request.SessionKey!; + var requestId = !string.IsNullOrWhiteSpace(request.CorrelationId) + ? $"local-{request.CorrelationId}" + : $"local-{Guid.NewGuid():N}"; + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var detail = request.Command ?? string.Empty; + var inline = new LocalInlineApproval(threadId, requestId, detail, response); + + ChatDataSnapshot snapshot; + lock (_gate) + { + if (_disposed) + return null; + + _localInlineApprovals[requestId] = inline; + var meta = BuildLiveMetaLocked(threadId); + snapshot = ApplyEventLocked( + threadId, + new ChatPermissionRequestEvent( + requestId, + LocalizationHelper.GetString("Chat_Permission_CommandApprovalTitle"), + request.Shell ?? "exec", + detail, + ChatPermissionActionKeys.ExecApprovalDefaults), + meta); + } + Publish(snapshot); + + using var registration = cancellationToken.Register(() => + TryResolveLocalInlineApproval(threadId, requestId, ChatPermissionActionKeys.Deny)); + + var decision = await response.Task.ConfigureAwait(false); + return decision switch + { + ChatPermissionActionKeys.AllowOnce => ExecApprovalPromptDecision.AllowOnce(), + ChatPermissionActionKeys.AllowAlways => ExecApprovalPromptDecision.AlwaysAllow(), + _ => ExecApprovalPromptDecision.Deny() + }; + } + + private bool TryResolveLocalInlineApproval(string threadId, string requestId, string decision) + { + LocalInlineApproval inline; + ChatDataSnapshot snapshot; + lock (_gate) + { + if (!_localInlineApprovals.TryGetValue(requestId, out var found) || + !string.Equals(found.ThreadId, threadId, StringComparison.Ordinal)) + { + return false; + } + + inline = found; + _localInlineApprovals.Remove(requestId); + var current = GetOrCreateTimelineLocked(threadId); + _timelines[threadId] = ChatTimelineReducer.ResolvePermission( + current, + requestId, + ChatDecisionForApprovalAction(decision)); + var meta = BuildLiveMetaLocked(threadId); + snapshot = ApplyEventLocked( + threadId, + new ChatStatusEvent(FormatApprovalResult(decision, requestId), ApprovalToneForDecision(decision)), + meta); + } + + Publish(snapshot); + inline.Response.TrySetResult(decision); + return true; + } + + private static string FormatApprovalResult(string decision, string requestId) + => string.Format( + System.Globalization.CultureInfo.CurrentCulture, + LocalizationHelper.GetString("Chat_Permission_ResultSubmittedFormat"), + decision, + requestId); + + private static ChatTone ApprovalToneForDecision(string decision) + => string.Equals(decision, ChatPermissionActionKeys.Deny, StringComparison.OrdinalIgnoreCase) + ? ChatTone.Warning + : ChatTone.Success; + + private static string NormalizeApprovalAction(string? action) + { + if (string.Equals(action, ChatPermissionActionKeys.AllowAlways, StringComparison.OrdinalIgnoreCase)) + return ChatPermissionActionKeys.AllowAlways; + if (string.Equals(action, ChatPermissionActionKeys.AllowOnce, StringComparison.OrdinalIgnoreCase)) + return ChatPermissionActionKeys.AllowOnce; + return ChatPermissionActionKeys.Deny; } + private static ChatPermissionDecision ChatDecisionForApprovalAction(string action) + => string.Equals(action, ChatPermissionActionKeys.AllowAlways, StringComparison.OrdinalIgnoreCase) + ? ChatPermissionDecision.AllowedAlways + : string.Equals(action, ChatPermissionActionKeys.Deny, StringComparison.OrdinalIgnoreCase) + ? ChatPermissionDecision.Denied + : ChatPermissionDecision.Allowed; + // expectedRequestId: when non-null, the clear is a no-op unless the // currently-pending banner's RequestId matches. This protects against // the responder-race where a fresh approval arrives between the @@ -989,6 +1113,7 @@ public ValueTask DisposeAsync() _disposed = true; System.Threading.Timer? timerToDispose; System.Threading.Timer? chatStateTimerToDispose; + List pendingLocalApprovals; lock (_gate) { timerToDispose = _toolMetaSaveTimer; @@ -996,7 +1121,11 @@ public ValueTask DisposeAsync() _toolMetaSaveVersion++; chatStateTimerToDispose = _lastChatStateSaveTimer; _lastChatStateSaveTimer = null; + pendingLocalApprovals = _localInlineApprovals.Values.ToList(); + _localInlineApprovals.Clear(); } + foreach (var approval in pendingLocalApprovals) + approval.Response.TrySetResult(ChatPermissionActionKeys.Deny); timerToDispose?.Dispose(); chatStateTimerToDispose?.Dispose(); SaveToolMetaCache(); @@ -1289,6 +1418,7 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (roleLower == "user") { // Approval slash-commands ("/approve allow-once", + // "/approve allow-always", // "/deny ") are transport, not user prose. If WE sent // it (matched + consumed from _localSentTexts) suppress the // echo entirely — RespondToPermissionAsync already cleared @@ -1552,6 +1682,9 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) var evtSlug = evt.Data.TryGetProperty("approvalSlug", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String ? (s.GetString() ?? "") : ""; + var evtDecision = evt.Data.TryGetProperty("decision", out var d) && d.ValueKind == System.Text.Json.JsonValueKind.String + ? (d.GetString() ?? "") + : ""; string? pendingId; lock (_gate) @@ -1571,11 +1704,10 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) // RPC response on the same WebSocket — if Expired wins // here, ResolvePermission's no-overwrite guard then // blocks the user's Allow/Denied stamp from landing. - // Phase already passed IsTerminalApprovalPhase; map - // resolved → Allowed, denied → Denied, and treat the - // remaining non-decided terminal phases (aborted, - // canceled, expired, timeout, error) as Expired. - var resolvedDecision = MapTerminalPhaseToDecision(phase); + // Phase already passed IsTerminalApprovalPhase; use + // the exact decision when present so allow-always is + // preserved, then fall back to phase mapping. + var resolvedDecision = MapTerminalPhaseToDecision(phase, evtDecision); ClearPendingPermissionAndPublish(threadId, expectedRequestId: pendingId, decision: resolvedDecision); } else @@ -1910,8 +2042,15 @@ private static bool IsTerminalApprovalPhase(string phase) // Allowed. ``denied`` maps to Denied. Every other terminal phase (aborted, // canceled/cancelled, expired, timeout, error) collapses to Expired — the // "decided elsewhere or never decided" badge. - private static ChatPermissionDecision MapTerminalPhaseToDecision(string phase) + private static ChatPermissionDecision MapTerminalPhaseToDecision(string phase, string? decision = null) { + if (string.Equals(decision, ChatPermissionActionKeys.AllowAlways, System.StringComparison.OrdinalIgnoreCase)) + return ChatPermissionDecision.AllowedAlways; + if (string.Equals(decision, ChatPermissionActionKeys.AllowOnce, System.StringComparison.OrdinalIgnoreCase)) + return ChatPermissionDecision.Allowed; + if (string.Equals(decision, ChatPermissionActionKeys.Deny, System.StringComparison.OrdinalIgnoreCase)) + return ChatPermissionDecision.Denied; + if (string.Equals(phase, "resolved", System.StringComparison.OrdinalIgnoreCase)) return ChatPermissionDecision.Allowed; if (string.Equals(phase, "denied", System.StringComparison.OrdinalIgnoreCase)) @@ -2132,7 +2271,7 @@ static string SafeStr(System.Text.Json.JsonElement obj, string name) detail = string.IsNullOrEmpty(detail) ? message : message + "\n\n" + detail; Logger.Info($"[Approval] emitting ChatPermissionRequestEvent requestId={requestId} kind='{permissionKind}' tool='{toolName}' detail.len={detail.Length}"); - return new ChatPermissionRequestEvent(requestId, permissionKind, toolName, detail); + return new ChatPermissionRequestEvent(requestId, permissionKind, toolName, detail, ChatPermissionActionKeys.ExecApprovalDefaults); } private static ChatEvent? MapAssistantEvent(AgentEventInfo evt) @@ -2543,7 +2682,8 @@ internal static string TruncateForChatEntry(string? text) /// /// /// True when text is one of the approval slash-commands we send on the - /// user's behalf (/approve <slug> allow-once or + /// user's behalf (/approve <slug> allow-once, + /// /approve <slug> allow-always, or /// /deny <slug>). Matches the exact dashboard grammar /// — not just the prefix — so legitimate user prose like /// "/approve the design changes" still renders as a normal bubble. @@ -2562,7 +2702,7 @@ internal static bool LooksLikeApprovalSlashCommand(string text) } private static readonly System.Text.RegularExpressions.Regex s_approvalSlashCommandRegex = - new(@"^/(?:approve\s+[A-Za-z0-9_-]{4,64}(?:\s+allow-once)?|deny\s+[A-Za-z0-9_-]{4,64})\s*$", + new(@"^/(?:approve\s+[A-Za-z0-9_-]{4,64}(?:\s+(?:allow-once|allow-always))?|deny\s+[A-Za-z0-9_-]{4,64})\s*$", System.Text.RegularExpressions.RegexOptions.Compiled); internal static bool LooksLikeSystemControlNote(string text) @@ -3464,6 +3604,21 @@ private ChatDataSnapshot BuildSnapshotLocked() }); } + foreach (var approval in _localInlineApprovals.Values) + { + if (threadList.Any(s => string.Equals(s.Id, approval.ThreadId, StringComparison.Ordinal))) + continue; + + threadList.Add(new ChatThread + { + Id = approval.ThreadId, + Title = _lastChatState?.ThreadTitle ?? "OpenClaw Windows Tray", + Status = ChatThreadStatus.Running, + Activity = ChatActivity.AwaitingPermission, + Model = _lastChatState?.Model, + }); + } + var threads = threadList.ToArray(); // Snapshot a defensive copy of the timeline dict. diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index ed293a833..e4a592831 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -567,7 +567,7 @@ Element BuildLoadingElement() : null, OnStopSpeaking: _onStopSpeaking, ScrollToBottomToken: scrollToBottomToken.Value, - OnPermissionResponse: (rid, allow) => OnPermission(effectiveThread.Id!, rid, allow))); + OnPermissionResponse: (rid, action) => OnPermission(effectiveThread.Id!, rid, action))); } // Session list for the composer dropdown — grouped by agent, keyed by @@ -953,9 +953,9 @@ private void OnStop(string threadId) RunFireAndForget(ct => _provider.StopResponseAsync(threadId, ct)); } - private void OnPermission(string threadId, string requestId, bool allow) + private void OnPermission(string threadId, string requestId, string action) { - RunFireAndForget(ct => _provider.RespondToPermissionAsync(threadId, requestId, allow, ct)); + RunFireAndForget(ct => _provider.RespondToPermissionAsync(threadId, requestId, action, ct)); } private static void RunFireAndForget(Func op) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index de139ec3a..8863423c3 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -62,7 +62,7 @@ public record OpenClawChatTimelineProps( Func? OnReadAloud = null, Action? OnStopSpeaking = null, int ScrollToBottomToken = 0, - Action? OnPermissionResponse = null); + Action? OnPermissionResponse = null); /// /// OpenClaw-skinned variant of from the vendored @@ -2253,11 +2253,45 @@ Element RenderPermissionEntry(ChatTimelineItem entry) var detail = entry.Text; var onResponse = Props.OnPermissionResponse; + static bool ActionEquals(string? action, string expected) => + string.Equals(action, expected, StringComparison.OrdinalIgnoreCase); + + string LabelForAction(string action) => + ActionEquals(action, ChatPermissionActionKeys.AllowOnce) ? LocalizationHelper.GetString("Chat_Permission_Allow") : + ActionEquals(action, ChatPermissionActionKeys.AllowAlways) ? LocalizationHelper.GetString("Chat_Permission_AllowAlways") : + ActionEquals(action, ChatPermissionActionKeys.Deny) ? LocalizationHelper.GetString("Chat_Permission_Deny") : + action; + + var actionKeys = ChatPermissionActionKeys.NormalizeActions(entry.PermissionActions); + Element body; if (entry.PermissionDecision == ChatPermissionDecision.Pending) { - var allowLabel = LocalizationHelper.GetString("Chat_Permission_Allow"); - var denyLabel = LocalizationHelper.GetString("Chat_Permission_Deny"); + Element PermissionActionButton(string actionKey, int index) + { + var label = LabelForAction(actionKey); + var isAccent = ActionEquals(actionKey, ChatPermissionActionKeys.AllowOnce) + || (!actionKeys.Any(a => ActionEquals(a, ChatPermissionActionKeys.AllowOnce)) + && index == 0 + && !ActionEquals(actionKey, ChatPermissionActionKeys.Deny)); + + return Button(label, + () => onResponse?.Invoke(requestId, actionKey)) + .Set(b => + { + b.CornerRadius = new CornerRadius(4); + b.Padding = new Thickness(14, 6, 14, 6); + b.MinWidth = 0; b.MinHeight = 0; + b.IsEnabled = onResponse is not null && !string.IsNullOrEmpty(requestId); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(b, $"{label}{automationSuffix}"); + if (isAccent) + { + try { b.Style = (Microsoft.UI.Xaml.Style)Microsoft.UI.Xaml.Application.Current.Resources["AccentButtonStyle"]; } + catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"ChatTimeline: accent button style lookup failed: {ex.Message}"); } + } + }); + } + body = VStack(8, TextBlock($"⚠ {kind}") .Set(t => { t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold; t.TextWrapping = TextWrapping.Wrap; }), @@ -2282,32 +2316,8 @@ Element RenderPermissionEntry(ChatTimelineItem entry) }), TextBlock(LocalizationHelper.GetString("Chat_Permission_Caption")) .Set(t => { t.TextWrapping = TextWrapping.Wrap; t.FontSize = 11; t.Opacity = 0.7; }), - HStack(8, - Button(allowLabel, - () => onResponse?.Invoke(requestId, true)) - .Set(b => - { - b.CornerRadius = new CornerRadius(4); - b.Padding = new Thickness(14, 6, 14, 6); - b.MinWidth = 0; b.MinHeight = 0; - b.IsEnabled = onResponse is not null && !string.IsNullOrEmpty(requestId); - // Include the operation kind in the screen-reader name so - // users hear "Allow shell.exec" instead of bare "Allow". - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(b, $"{allowLabel}{automationSuffix}"); - try { b.Style = (Microsoft.UI.Xaml.Style)Microsoft.UI.Xaml.Application.Current.Resources["AccentButtonStyle"]; } - catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"ChatTimeline: accent button style lookup failed: {ex.Message}"); } - }), - Button(denyLabel, - () => onResponse?.Invoke(requestId, false)) - .Set(b => - { - b.CornerRadius = new CornerRadius(4); - b.Padding = new Thickness(14, 6, 14, 6); - b.MinWidth = 0; b.MinHeight = 0; - b.IsEnabled = onResponse is not null && !string.IsNullOrEmpty(requestId); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(b, $"{denyLabel}{automationSuffix}"); - }) - ).HAlign(HorizontalAlignment.Right) + HStack(8, actionKeys.Select(PermissionActionButton).ToArray()) + .HAlign(HorizontalAlignment.Right) ); } else @@ -2317,9 +2327,10 @@ Element RenderPermissionEntry(ChatTimelineItem entry) // was approved/denied without expanding anything. var (glyph, labelKey) = entry.PermissionDecision switch { - ChatPermissionDecision.Allowed => ("✓", "Chat_Permission_DecisionAllowed"), - ChatPermissionDecision.Denied => ("✕", "Chat_Permission_DecisionDenied"), - _ => ("⌛", "Chat_Permission_DecisionExpired"), + ChatPermissionDecision.Allowed => ("✓", "Chat_Permission_DecisionAllowed"), + ChatPermissionDecision.AllowedAlways => ("✓", "Chat_Permission_DecisionAlwaysAllowed"), + ChatPermissionDecision.Denied => ("✕", "Chat_Permission_DecisionDenied"), + _ => ("⌛", "Chat_Permission_DecisionExpired"), }; var label = LocalizationHelper.GetString(labelKey); // Surrogate-safe truncation: if char 119 is a high surrogate, diff --git a/src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs b/src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs new file mode 100644 index 000000000..3aa083b47 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Pages/ExecPolicyRuleList.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; + +namespace OpenClawTray.Pages; + +internal sealed class ExecPolicyRule +{ + public string Pattern { get; set; } = ""; + public string Action { get; set; } = "deny"; + public int Index { get; set; } +} + +internal static class ExecPolicyRuleList +{ + public static void UpsertByPattern(IList rules, string pattern, string action) + { + ArgumentNullException.ThrowIfNull(rules); + + var normalizedPattern = pattern.Trim(); + if (normalizedPattern.Length == 0) + return; + + var firstMatch = -1; + for (var i = 0; i < rules.Count; i++) + { + if (PatternEquals(rules[i].Pattern, normalizedPattern)) + { + firstMatch = i; + break; + } + } + + if (firstMatch < 0) + { + rules.Add(new ExecPolicyRule { Pattern = normalizedPattern, Action = action }); + return; + } + + rules[firstMatch].Pattern = normalizedPattern; + rules[firstMatch].Action = action; + + for (var i = rules.Count - 1; i > firstMatch; i--) + { + if (PatternEquals(rules[i].Pattern, normalizedPattern)) + rules.RemoveAt(i); + } + } + + public static bool CoalesceDuplicatePatterns(IList rules) + { + ArgumentNullException.ThrowIfNull(rules); + + var changed = false; + for (var i = 0; i < rules.Count; i++) + { + ExecPolicyRule? lastDuplicate = null; + for (var j = i + 1; j < rules.Count; j++) + { + if (PatternEquals(rules[i].Pattern, rules[j].Pattern)) + lastDuplicate = rules[j]; + } + + if (lastDuplicate is null) + continue; + + // Loading an existing file must not relax or tighten policy just + // because duplicate patterns exist; exec policy is first-match-wins. + rules[i].Pattern = rules[i].Pattern.Trim(); + + for (var j = rules.Count - 1; j > i; j--) + { + if (PatternEquals(rules[i].Pattern, rules[j].Pattern)) + rules.RemoveAt(j); + } + + changed = true; + } + + return changed; + } + + private static bool PatternEquals(string currentPattern, string newPattern) => + string.Equals(currentPattern.Trim(), newPattern.Trim(), StringComparison.OrdinalIgnoreCase); +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml index b18a66a23..f5d815ace 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml @@ -297,7 +297,7 @@ - - + + +