diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index e0e90515..fd26fdac 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 4963e73b..94cca178 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 f18061d0..3b682d37 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 64400824..cbcbcf5f 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 622b3144..d090a505 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 1c3feed2..12358e3c 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 52859f02..8ca7c812 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 3ca68543..b72a848c 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 643d9a82..fbaa5ad7 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 9f9110e3..5b2b71e3 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 ed293a83..e4a59283 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 de139ec3..8863423c 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 00000000..3aa083b4 --- /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 b18a66a2..f5d815ac 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml @@ -297,7 +297,7 @@ - - + + +