Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CONNECTION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ The bootstrap token is cleared only after operator and node role tokens are both

**Approval boundaries**: `GatewayConnectionManager` leaves node-pair command-trust requests and reapproval pending for explicit operator approval. It may automatically approve and reconnect only an explicitly typed device-pair request used for a device role upgrade.

In-chat exec approval cards sanitize the gateway's command and message before rendering. If either cannot be reviewed in full because it is truncated, suppressed, or conceals command syntax, the card offers only Deny; the chat provider also rejects Allow RPCs that are not permitted by the matching pending card.

## Inbound pairing approval (operator)

When **another** device or node requests pairing, the gateway broadcasts `device.pair.requested` / `node.pair.requested` to operators with pairing scope. `OpenClawGatewayClient` refreshes the pending lists and raises `DevicePairListUpdated` / `NodePairListUpdated`, which `GatewayService` forwards via its `PairListsChanged` event.
Expand Down
3 changes: 3 additions & 0 deletions docs/GATEWAY_FIXTURE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ writes receive protocol errors, not simulated success. Local preferences such
as chat tool visibility can be saved inside the disposable profile. This is
not a fixture for the legacy WebView Gateway dashboard, installation,
uninstallation, host management, or node command execution.
The one write-shaped exception is `exec.approval.resolve`: the fixture validates
the fixture-owned approval ID and decision, records only that safe correlation,
and returns a synthetic acknowledgement without executing a command.

The host is `tests\OpenClaw.GatewayFixtureHost`, a plain .NET console app, not a
test container. The request-driven server and scenario live in
Expand Down
12 changes: 12 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,18 @@ internal bool TryRaiseKeylessDiagnostic()
return GetOrCreateTimelineLocked(threadId).PendingPermission?.RequestId;
}

internal bool CanRespondToPermission(string threadId, string requestId, string action)
{
lock (_gate)
{
var pending = GetOrCreateTimelineLocked(threadId).PendingPermission;
return pending is not null
&& string.Equals(pending.RequestId, requestId, StringComparison.Ordinal)
&& ChatPermissionActionKeys.NormalizeActions(pending.Actions)
.Contains(action, StringComparer.OrdinalIgnoreCase);
}
}

internal void ActivateHistoryGeneration(long generation)
{
lock (_gate)
Expand Down
25 changes: 19 additions & 6 deletions src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using OpenClaw.Chat;
using OpenClaw.Shared;
using OpenClaw.Shared.ExecApprovals;

namespace OpenClawTray.Chat;

Expand Down Expand Up @@ -462,18 +463,30 @@ private static ChatEventMapping MapApproval(AgentEventInfo evt)
return new(null);

var host = StringProperty(evt.Data, "host");
var command = StringProperty(evt.Data, "command");
var commandStatus = ExecApprovalCommandDisplaySanitizer.SanitizeWithStatus(StringProperty(evt.Data, "command"));
var title = StringProperty(evt.Data, "title");
var message = StringProperty(evt.Data, "message");
var detail = string.IsNullOrEmpty(message)
? command
: string.IsNullOrEmpty(command) ? message : message + "\n\n" + command;
var messageStatus = ExecApprovalCommandDisplaySanitizer.SanitizeWithStatus(StringProperty(evt.Data, "message"));
var detail = string.IsNullOrEmpty(messageStatus.Text)
? commandStatus.Text
: string.IsNullOrEmpty(commandStatus.Text)
? messageStatus.Text
: messageStatus.Text + "\n\n" + commandStatus.Text;
if (string.IsNullOrWhiteSpace(commandStatus.Text))
{
const string missingCommand =
"No command was included with this approval, so only Deny is available.";
detail = string.IsNullOrEmpty(detail) ? missingCommand : detail + "\n\n" + missingCommand;
}

var canReviewInFull = !string.IsNullOrWhiteSpace(commandStatus.Text)
&& !commandStatus.Truncated && !commandStatus.Oversized && !commandStatus.UnsafeConcealment
&& !messageStatus.Truncated && !messageStatus.Oversized && !messageStatus.UnsafeConcealment;
var mapped = new ChatPermissionRequestEvent(
requestId,
!string.IsNullOrEmpty(title) ? title : "Exec approval",
!string.IsNullOrEmpty(host) ? host : "node",
detail,
ChatPermissionActionKeys.ExecApprovalDefaults);
canReviewInFull ? ChatPermissionActionKeys.ExecApprovalDefaults : [ChatPermissionActionKeys.Deny]);
var alternateId = !string.IsNullOrEmpty(slug) ? approvalId : slug;
return new(mapped, new ChatApprovalIdentity(requestId, alternateId));
}
Expand Down
5 changes: 5 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,11 @@ public async Task RespondToPermissionAsync(string threadId, string requestId, st
return;

var decision = NormalizeApprovalAction(action);
if (!_state.CanRespondToPermission(threadId, requestId, decision))
{
Logger.Warn("[Approval] response blocked: no matching pending approval permits this decision");
return;
}
// Use the operator-approvals gateway RPC (``exec.approval.resolve``)
// rather than the ``/approve <id> <decision>`` chat slash command.
//
Expand Down
117 changes: 116 additions & 1 deletion tests/OpenClaw.Shared.Tests/GatewayFixtureProtocolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,6 @@ public async Task RealClient_InvalidOrUnknownParametersFailExplicitly(string met
[InlineData("config.patch")]
[InlineData("config.apply")]
[InlineData("node.invoke")]
[InlineData("exec.approval.resolve")]
public async Task RealClient_WritesAreRejectedWithoutChangingScenario(string method)
{
var token = CreateToken();
Expand All @@ -259,6 +258,122 @@ public async Task RealClient_WritesAreRejectedWithoutChangingScenario(string met
Assert.Empty(server.UnexpectedRequests);
}

[Fact]
public async Task RealClient_ApprovalResolveGetsSyntheticAckAndRecordsSafeCorrelation()
{
var token = CreateToken();
await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token);
await using var connected = await ConnectedClient.OpenAsync(server, token);
await server.PublishAgentEventAsync(GatewayScenario.MainSessionKey, new
{
phase = "requested",
approvalId = "fixture-approval-1",
command = "echo fixture",
});

await connected.Client.ResolveExecApprovalAsync("fixture-approval-1", "allow-once");

var request = await server.WaitForCompletedRequestAsync("exec.approval.resolve");
Assert.Equal("fixture-approval-1", request.ApprovalId);
Assert.Equal("allow-once", request.Decision);
Assert.Equal("ok", request.Outcome);
Assert.Empty(server.UnexpectedRequests);
}

[Fact]
public async Task RealClient_ApprovalResolveInvalidDecisionDoesNotConsumeIssuedId()
{
var token = CreateToken();
await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token);
await using var connected = await ConnectedClient.OpenAsync(server, token);
await server.PublishAgentEventAsync(GatewayScenario.MainSessionKey, new
{
phase = "requested",
approvalId = "fixture-approval-invalid",
command = "echo fixture",
});

var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
connected.Client.SendWizardRequestAsync(
"exec.approval.resolve",
new { id = "fixture-approval-invalid", decision = "approve" }));
await connected.Client.ResolveExecApprovalAsync("fixture-approval-invalid", "allow-once");

Assert.Contains("Unsupported approval decision", error.Message);
var requests = server.Requests
.Where(request => request.Method == "exec.approval.resolve")
.ToArray();
Assert.Collection(
requests,
request =>
{
Assert.Equal("fixture-approval-invalid", request.ApprovalId);
Assert.Equal("<unknown>", request.Decision);
Assert.Equal("error:INVALID_PARAMS", request.Outcome);
},
request =>
{
Assert.Equal("fixture-approval-invalid", request.ApprovalId);
Assert.Equal("allow-once", request.Decision);
Assert.Equal("ok", request.Outcome);
});
Assert.Empty(server.UnexpectedRequests);
}

[Fact]
public async Task RealClient_ApprovalResolveRejectsUnissuedFixtureId()
{
var token = CreateToken();
await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token);
await using var connected = await ConnectedClient.OpenAsync(server, token);

var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
connected.Client.ResolveExecApprovalAsync("fixture-never-issued", "deny"));

Assert.Contains("not issued by the fixture", error.Message);
var request = Assert.Single(server.Requests, request => request.Method == "exec.approval.resolve");
Assert.Equal("fixture-never-issued", request.ApprovalId);
Assert.Equal("deny", request.Decision);
Assert.Equal("error:INVALID_PARAMS", request.Outcome);
Assert.Empty(server.UnexpectedRequests);
}

[Fact]
public async Task RealClient_ApprovalResolveAcknowledgesIssuedIdOnlyOnce()
{
var token = CreateToken();
await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token);
await using var connected = await ConnectedClient.OpenAsync(server, token);
await server.PublishAgentEventAsync(GatewayScenario.MainSessionKey, new
{
phase = "requested",
approvalId = "fixture-one-shot",
command = "echo fixture",
});

var attempts = await Task.WhenAll(Enumerable.Range(0, 2).Select(async _ =>
{
try
{
await connected.Client.ResolveExecApprovalAsync("fixture-one-shot", "allow-once");
return "ok";
}
catch (InvalidOperationException ex)
{
Assert.Contains("not issued by the fixture", ex.Message);
return "rejected";
}
}));

Assert.Equal(1, attempts.Count(result => result == "ok"));
Assert.Equal(1, attempts.Count(result => result == "rejected"));
Assert.Contains(server.Requests, request =>
request.Method == "exec.approval.resolve" && request.Outcome == "ok");
Assert.Contains(server.Requests, request =>
request.Method == "exec.approval.resolve" && request.Outcome == "error:INVALID_PARAMS");
Assert.Empty(server.UnexpectedRequests);
}

[Fact]
public async Task RealClient_UnknownMethodIsRecordedAndNeverReturnsFallbackSuccessOrCredentials()
{
Expand Down
Loading
Loading