Skip to content

Commit 4e7982b

Browse files
RBridCopilot
andauthored
Add inline exec approval flow
Routes local system.run approval prompts into native chat when an interactive chat surface is available, while preserving the native approval dialog fallback. Maintainer updates: - Rebased onto current main after the MXC/pairing port landed. - Kept the deleted Chat Explorations fake provider removed during rebase. - Removed a duplicate ChatWindow.Show() call introduced during the branch hardening pass. Validation: - Local .\build.ps1 passed. - Local Shared tests passed: 2417 passed / 29 skipped. - Local Tray tests passed: 1154 passed. - Independent code review found only the duplicate Show() issue, now fixed. - Independent security review found no concrete security vulnerabilities. - GitHub CI passed on repaired head ae301c0: repo-hygiene, test, E2E setup-connect, E2E revocation-recovery, E2E network-recovery, build win-x64, build win-arm64, Socket Security. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9f4d238 commit 4e7982b

37 files changed

Lines changed: 1781 additions & 242 deletions

src/OpenClaw.Chat/ChatModels.cs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,33 @@ public enum ChatTimelineItemKind
4545
/// </remarks>
4646
public enum ChatPermissionDecision
4747
{
48-
Pending,
49-
Allowed,
50-
Denied,
51-
Expired
48+
Pending = 0,
49+
Allowed = 1,
50+
Denied = 2,
51+
Expired = 3,
52+
AllowedAlways = 4
53+
}
54+
55+
public static class ChatPermissionActionKeys
56+
{
57+
public const string AllowOnce = "allow-once";
58+
public const string AllowAlways = "allow-always";
59+
public const string Deny = "deny";
60+
61+
public static readonly string[] ExecApprovalDefaults = [AllowOnce, AllowAlways, Deny];
62+
63+
public static string[] NormalizeActions(IReadOnlyList<string>? actions)
64+
{
65+
if (actions is not { Count: > 0 })
66+
return ExecApprovalDefaults;
67+
68+
var normalized = actions
69+
.Where(action => !string.IsNullOrWhiteSpace(action))
70+
.Distinct(StringComparer.OrdinalIgnoreCase)
71+
.ToArray();
72+
73+
return normalized.Length > 0 ? normalized : ExecApprovalDefaults;
74+
}
5275
}
5376

5477
public enum ChatToolCallStatus
@@ -107,9 +130,10 @@ public record ChatTimelineItem(
107130
ChatTone? Tone = null,
108131
string? ToolCallId = null,
109132
string? PermissionRequestId = null,
110-
ChatPermissionDecision PermissionDecision = ChatPermissionDecision.Pending);
133+
ChatPermissionDecision PermissionDecision = ChatPermissionDecision.Pending,
134+
IReadOnlyList<string>? PermissionActions = null);
111135

112-
public record ChatPermissionRequest(string RequestId, string PermissionKind, string ToolName, string Detail);
136+
public record ChatPermissionRequest(string RequestId, string PermissionKind, string ToolName, string Detail, IReadOnlyList<string>? Actions = null);
113137

114138
public record ChatTimelineState(
115139
System.Collections.Immutable.ImmutableList<ChatTimelineItem> Entries,
@@ -156,7 +180,7 @@ public record ChatContextChangedEvent(string? Cwd, string? GitBranch) : ChatEven
156180
public record ChatStatusEvent(string Text, ChatTone Tone) : ChatEvent;
157181
public record ChatErrorEvent(string Text) : ChatEvent;
158182
public record ChatRestoredEvent(string Text) : ChatEvent;
159-
public record ChatPermissionRequestEvent(string RequestId, string PermissionKind, string ToolName, string Detail) : ChatEvent;
183+
public record ChatPermissionRequestEvent(string RequestId, string PermissionKind, string ToolName, string Detail, IReadOnlyList<string>? Actions = null) : ChatEvent;
160184
public record ChatModelChangedEvent(string Model) : ChatEvent;
161185
public record ChatRawEvent(string EventType, string? Text = null) : ChatEvent;
162186

@@ -235,5 +259,11 @@ Task SendMessageAsync(string threadId, string message, CancellationToken cancell
235259
Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default);
236260
Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default);
237261
Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default);
238-
Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default);
262+
Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default);
263+
Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default) =>
264+
RespondToPermissionAsync(
265+
threadId,
266+
requestId,
267+
allow ? ChatPermissionActionKeys.AllowOnce : ChatPermissionActionKeys.Deny,
268+
cancellationToken);
239269
}

src/OpenClaw.Chat/ChatTimelineReducer.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,14 @@ static ChatTimelineState ApplyPermissionRequest(ChatTimelineState state, ChatPer
174174
ToolName: e.ToolName,
175175
IntentSummary: e.PermissionKind,
176176
PermissionRequestId: e.RequestId,
177-
PermissionDecision: ChatPermissionDecision.Pending);
177+
PermissionDecision: ChatPermissionDecision.Pending,
178+
PermissionActions: e.Actions);
178179

179180
return state with
180181
{
181182
Entries = entries.Add(entry),
182183
NextId = state.NextId + 1,
183-
PendingPermission = new ChatPermissionRequest(e.RequestId, e.PermissionKind, e.ToolName, detail)
184+
PendingPermission = new ChatPermissionRequest(e.RequestId, e.PermissionKind, e.ToolName, detail, e.Actions)
184185
};
185186
}
186187

src/OpenClaw.Shared/Capabilities/SystemCapability.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request)
277277
var rawCommand = GetStringArg(request.Args, "rawCommand");
278278
var cwd = GetStringArg(request.Args, "cwd");
279279
var agentId = GetStringArg(request.Args, "agentId");
280-
var sessionKey = GetStringArg(request.Args, "sessionKey");
280+
var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey");
281281

282282
Logger.Info($"system.run.prepare: {rawCommand} (cwd={cwd ?? "default"})");
283283

@@ -357,6 +357,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
357357

358358
var shell = GetStringArg(request.Args, "shell");
359359
var cwd = GetStringArg(request.Args, "cwd");
360+
var sessionKey = request.SessionKey ?? GetStringArg(request.Args, "sessionKey");
360361
var timeoutMs = GetIntArg(request.Args, "timeoutMs",
361362
GetIntArg(request.Args, "timeout", DefaultRunTimeoutMs));
362363
// Clamp caller-supplied timeouts. timeoutMs <= 0 historically meant
@@ -406,7 +407,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
406407
if (_approvalPolicy != null)
407408
{
408409
var approval = _approvalPolicy.Evaluate(fullCommand, shell);
409-
var approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval);
410+
var approvalCheck = await EnsureApprovedAsync(fullCommand, shell, approval, sessionKey, correlationId);
410411
if (!approvalCheck.Allowed)
411412
{
412413
Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})");
@@ -437,7 +438,7 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
437438
continue;
438439
}
439440

440-
var innerApprovalCheck = await EnsureApprovedAsync(target.Command, target.Shell, innerApproval);
441+
var innerApprovalCheck = await EnsureApprovedAsync(target.Command, target.Shell, innerApproval, sessionKey, correlationId);
441442
if (!innerApprovalCheck.Allowed)
442443
{
443444
Logger.Warn($"system.run DENIED: {target.Command} ({innerApproval.Reason})");
@@ -478,6 +479,8 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
478479
string command,
479480
string? shell,
480481
ExecApprovalResult approval,
482+
string? sessionKey,
483+
string correlationId,
481484
CancellationToken cancellationToken = default)
482485
{
483486
if (approval.Allowed)
@@ -494,7 +497,9 @@ private async Task<ExecApprovalCheckResult> EnsureApprovedAsync(
494497
Command = command,
495498
Shell = shell,
496499
MatchedPattern = approval.MatchedPattern,
497-
Reason = approval.Reason ?? "Command requires approval"
500+
Reason = approval.Reason ?? "Command requires approval",
501+
SessionKey = sessionKey,
502+
CorrelationId = correlationId
498503
}, cancellationToken);
499504

500505
if (decision.Kind == ExecApprovalPromptDecisionKind.Deny)

src/OpenClaw.Shared/ExecApprovalPolicy.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,28 @@ public enum ExecApprovalAction
4242

4343
/// <summary>
4444
/// JsonConverter for <see cref="ExecApprovalAction"/> that emits/accepts the canonical
45-
/// camelCase values ("allow", "deny", "prompt") but also accepts the legacy "ask" alias.
46-
/// Older builds of the Permissions UI wrote "ask" for the Prompt action; without this
47-
/// converter, deserialization would throw and the entire policy file (including any
48-
/// user-authored rules) would be silently replaced with the default policy on load.
45+
/// camelCase values ("allow", "deny", "prompt") but also accepts legacy values written
46+
/// by older builds: the "ask" alias and numeric enum values.
4947
/// </summary>
5048
internal sealed class ExecApprovalActionConverter : JsonConverter<ExecApprovalAction>
5149
{
5250
public override ExecApprovalAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
5351
{
52+
if (reader.TokenType == JsonTokenType.Number)
53+
{
54+
if (!reader.TryGetInt32(out var numericValue))
55+
throw new JsonException("Expected integer value for ExecApprovalAction");
56+
57+
return numericValue switch
58+
{
59+
(int)ExecApprovalAction.Allow => ExecApprovalAction.Allow,
60+
(int)ExecApprovalAction.Deny => ExecApprovalAction.Deny,
61+
(int)ExecApprovalAction.Prompt => ExecApprovalAction.Prompt,
62+
_ => throw new JsonException($"Unknown ExecApprovalAction numeric value '{numericValue}'")
63+
};
64+
}
5465
if (reader.TokenType != JsonTokenType.String)
55-
throw new JsonException($"Expected string for ExecApprovalAction, got {reader.TokenType}");
66+
throw new JsonException($"Expected string or number for ExecApprovalAction, got {reader.TokenType}");
5667

5768
var value = reader.GetString();
5869
return value?.ToLowerInvariant() switch

src/OpenClaw.Shared/ExecApprovalPrompt.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ public sealed class ExecApprovalPromptRequest
1717
public string? Shell { get; init; }
1818
public string? MatchedPattern { get; init; }
1919
public string Reason { get; init; } = "";
20+
public string? SessionKey { get; init; }
21+
public string? CorrelationId { get; init; }
2022
}
2123

2224
public sealed class ExecApprovalPromptDecision
2325
{
26+
public const string TimedOutReason = "Approval prompt timed out";
27+
2428
private ExecApprovalPromptDecision(ExecApprovalPromptDecisionKind kind, string reason)
2529
{
2630
Kind = kind;
@@ -33,6 +37,7 @@ private ExecApprovalPromptDecision(ExecApprovalPromptDecisionKind kind, string r
3337
public static ExecApprovalPromptDecision Deny(string reason = "Denied by user") => new(ExecApprovalPromptDecisionKind.Deny, reason);
3438
public static ExecApprovalPromptDecision AllowOnce(string reason = "Allowed once by user") => new(ExecApprovalPromptDecisionKind.AllowOnce, reason);
3539
public static ExecApprovalPromptDecision AlwaysAllow(string reason = "Always allowed by user") => new(ExecApprovalPromptDecisionKind.AlwaysAllow, reason);
40+
public static ExecApprovalPromptDecision TimedOut() => Deny(TimedOutReason);
3641
}
3742

3843
public interface IExecApprovalPromptHandler
@@ -52,6 +57,7 @@ public enum ExecApprovalPromptDecisionSource
5257
UserAllowOnce,
5358
UserAlwaysAllow,
5459
Cancelled,
60+
TimedOut,
5561
Failed,
5662
/// <summary>
5763
/// Policy denied the command non-interactively (e.g. default action is
@@ -77,3 +83,13 @@ public ExecApprovalPromptDecidedEventArgs(
7783
public ExecApprovalPromptDecision Decision { get; }
7884
public ExecApprovalPromptDecisionSource Source { get; }
7985
}
86+
87+
public sealed class ExecApprovalPromptRequestedEventArgs : EventArgs
88+
{
89+
public ExecApprovalPromptRequestedEventArgs(ExecApprovalPromptRequest request)
90+
{
91+
Request = request;
92+
}
93+
94+
public ExecApprovalPromptRequest Request { get; }
95+
}

src/OpenClaw.Shared/NodeCapabilities.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class NodeInvokeRequest
2424
public string Id { get; set; } = "";
2525
public string Command { get; set; } = "";
2626
public JsonElement Args { get; set; }
27+
public string? SessionKey { get; set; }
2728
}
2829

2930
public class NodeInvokeCompletedEventArgs : EventArgs

src/OpenClaw.Shared/WindowsNodeClient.cs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,8 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root)
481481
}
482482
}
483483
}
484+
485+
var sessionKey = ExtractNodeInvokeSessionKey(payload, args);
484486

485487
_logger.Info($"[NODE] Invoking command: {command}");
486488

@@ -489,7 +491,8 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root)
489491
{
490492
Id = requestId,
491493
Command = command,
492-
Args = args
494+
Args = args,
495+
SessionKey = sessionKey
493496
};
494497

495498
// Find capability that can handle this command
@@ -1041,14 +1044,16 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId)
10411044
var args = paramsEl.TryGetProperty("args", out var argsEl)
10421045
? argsEl.Clone()
10431046
: default;
1047+
var sessionKey = ExtractNodeInvokeSessionKey(paramsEl, args);
10441048

10451049
_logger.Info($"Received node.invoke: {command}");
10461050

10471051
var request = new NodeInvokeRequest
10481052
{
10491053
Id = requestId,
10501054
Command = command,
1051-
Args = args
1055+
Args = args,
1056+
SessionKey = sessionKey
10521057
};
10531058

10541059
// Find capability that can handle this command
@@ -1109,6 +1114,28 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId)
11091114
}, CancellationToken.None);
11101115
}
11111116

1117+
private static string? ExtractNodeInvokeSessionKey(JsonElement envelope, JsonElement args)
1118+
{
1119+
if (envelope.TryGetProperty("sessionKey", out var envelopeSessionKey) &&
1120+
envelopeSessionKey.ValueKind == JsonValueKind.String)
1121+
{
1122+
var sessionKey = envelopeSessionKey.GetString();
1123+
if (!string.IsNullOrWhiteSpace(sessionKey))
1124+
return sessionKey;
1125+
}
1126+
1127+
if (args.ValueKind == JsonValueKind.Object &&
1128+
args.TryGetProperty("sessionKey", out var argsSessionKey) &&
1129+
argsSessionKey.ValueKind == JsonValueKind.String)
1130+
{
1131+
var sessionKey = argsSessionKey.GetString();
1132+
if (!string.IsNullOrWhiteSpace(sessionKey))
1133+
return sessionKey;
1134+
}
1135+
1136+
return null;
1137+
}
1138+
11121139
private void RaiseInvokeCompleted(string requestId, string command, bool ok, string? error, TimeSpan duration)
11131140
{
11141141
InvokeCompleted?.Invoke(this, new NodeInvokeCompletedEventArgs

0 commit comments

Comments
 (0)