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
30 changes: 28 additions & 2 deletions src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,30 @@ public record ChatDataSnapshot(
IReadOnlyDictionary<string, ChatTimelineState> Timelines,
string? DefaultThreadId,
string? ConnectionStatus,
string[] AvailableModels);
string[] AvailableModels,
ChatComposeTarget ComposeTarget);

/// <summary>
/// Describes where the UI may send the next chat message. Distinct from
/// <see cref="ChatDataSnapshot.Threads"/> because, in protocols like the
/// OpenClaw gateway, there is always a canonical "main" target whose session
/// row may not have been materialized yet (zero sessions on fresh install).
/// The UI uses this to decide whether the composer should be enabled even
/// when <see cref="ChatDataSnapshot.Threads"/> is empty.
/// </summary>
/// <param name="SessionKey">
/// The provider-resolved canonical session key for the default send target,
/// or <c>null</c> when not yet known (e.g. handshake incomplete).
/// </param>
/// <param name="IsReady">
/// True when the provider is connected, has resolved a canonical send target,
/// and accepts <see cref="IChatDataProvider.SendMessageAsync"/> calls keyed by
/// <see cref="SessionKey"/>.
/// </param>
public sealed record ChatComposeTarget(string? SessionKey, bool IsReady)
{
public static ChatComposeTarget NotReady { get; } = new(null, false);
}

public sealed class ChatDataChangedEventArgs(ChatDataSnapshot snapshot) : EventArgs
{
Expand Down Expand Up @@ -157,7 +180,10 @@ public interface IChatDataProvider : IAsyncDisposable
event EventHandler<ChatProviderNotificationEventArgs>? NotificationRequested;

Task<ChatDataSnapshot> LoadAsync(CancellationToken cancellationToken = default);
Task<ChatThread> CreateThreadAsync(string? initialMessage = null, CancellationToken cancellationToken = default);
// Note: there is intentionally no CreateThreadAsync. The gateway protocol
// has no "create new session" RPC; the canonical send target is exposed via
// ChatDataSnapshot.ComposeTarget and the first SendMessageAsync against it
// implicitly materializes the session on the server.
Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default);
Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken, IReadOnlyList<OpenClaw.Shared.ChatAttachment>? attachments) =>
SendMessageAsync(threadId, message, cancellationToken);
Expand Down
151 changes: 137 additions & 14 deletions src/OpenClaw.Connection/GatewayConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,22 @@ private async Task ConnectCoreAsync(string? gatewayId = null)
_gatewayNeedsV2Signature = true;
};

// Operator-side auto-approve: the node-side WindowsNodeClient
// only sees its own pairing state. But when the gateway broadcasts
// a node.pair.requested event, the OPERATOR client gets a fresh
// NodePairListUpdated push. If our own node deviceId shows up in
// that pending list and we have approval scopes, approve it
// immediately — otherwise the user is stuck looking at a connected
// node with empty capabilities until they manually approve.
// This complements (not replaces) the node-side flow on line ~810
// which still handles the case where the node knows it's pending
// before any operator event lands.
lifecycle.DataClient.NodePairListUpdated += (s, info) =>
{
if (Interlocked.Read(ref _generation) != gen) return;
_ = TryOperatorAutoApproveOwnNodePairAsync(info, gen);
};

// If we already know this gateway needs v2, tell the client upfront
if (_gatewayNeedsV2Signature)
lifecycle.DataClient.UseV2Signature = true;
Expand Down Expand Up @@ -841,17 +857,23 @@ private async void OnNodePairingStatusChanged(object? sender, PairingStatusEvent
_transitionSemaphore.Release();
}

// Auto-approve node pairing if operator has admin/pairing scope
// Auto-approve node pairing if operator has admin/pairing scope.
// _autoApproveInFlight is a CAS guard scoped to JUST the approve RPC —
// we release it before the reconnect delay so unrelated approvals
// (different requestIds) aren't starved while we wait for the gateway
// and node-reconnect handshake to settle (which can take 5–30s on
// first connect via WSL cold-start).
if (e.Status == PairingStatus.Pending && !string.IsNullOrWhiteSpace(e.RequestId)
&& e.RequestId != _lastAutoApprovedRequestId)
{
// Atomic guard: only one approval in-flight at a time.
// If another approval is already running, skip this one entirely.
if (Interlocked.CompareExchange(ref _autoApproveInFlight, e.RequestId, null) != null)
{
return;
}

var approvalGeneration = Interlocked.Read(ref _generation);
bool attemptedApprove = false;
bool approved = false;
try
{
var operatorClient = _activeLifecycle?.DataClient;
Expand All @@ -865,18 +887,10 @@ private async void OnNodePairingStatusChanged(object? sender, PairingStatusEvent
_diagnostics.Record("node", $"Auto-approving node pairing (requestId={e.RequestId})");
try
{
var approved = await operatorClient.NodePairApproveAsync(e.RequestId);
if (approved)
{
_lastAutoApprovedRequestId = e.RequestId;
_diagnostics.Record("node", "Node pairing auto-approved — reconnecting node");
await Task.Delay(1000); // brief delay for gateway to process
await StartNodeConnectionAsync();
}
else
{
attemptedApprove = true;
approved = await operatorClient.NodePairApproveAsync(e.RequestId);
if (!approved)
_diagnostics.Record("node", "Node auto-approval failed");
}
}
catch (Exception ex)
{
Expand All @@ -888,8 +902,117 @@ private async void OnNodePairingStatusChanged(object? sender, PairingStatusEvent
}
finally
{
// Only dedupe after an actual approve attempt. If the operator
// client was disconnected or lacked scope, the operator-side
// NodePairListUpdated path must still be able to approve this
// same requestId once the operator is ready.
if (attemptedApprove && Interlocked.Read(ref _generation) == approvalGeneration)
_lastAutoApprovedRequestId = e.RequestId;
Interlocked.Exchange(ref _autoApproveInFlight, null);
}

// Post-approve reconnect happens OUTSIDE the CAS guard so it
// doesn't block unrelated approvals.
if (approved)
{
_diagnostics.Record("node", "Node pairing auto-approved — reconnecting node");
await Task.Delay(1000); // brief delay for gateway to process
if (Interlocked.Read(ref _generation) == approvalGeneration)
await StartNodeConnectionAsync();
}
}
}

/// <summary>
/// Operator-side auto-approve. When the gateway pushes
/// <see cref="OpenClawGatewayClient.NodePairListUpdated"/> and there is a
/// pending entry for our OWN node's deviceId, approve it. The node-side
/// auto-approve at <see cref="OnNodePairingStatusChanged"/> handles the
/// case where the node already knows it is pending; this method handles
/// the case where the node is device-paired (its WindowsNodeClient sees
/// itself as Paired) but its node-sub-pairing hasn't been approved yet —
/// the only signal for that case is the operator-side broadcast.
/// </summary>
private async Task TryOperatorAutoApproveOwnNodePairAsync(PairingListInfo? info, long gen)
{
if (info?.Pending == null || info.Pending.Count == 0) return;

var ownNodeId = _nodeConnector?.NodeDeviceId;
if (string.IsNullOrWhiteSpace(ownNodeId)) return;

var operatorClient = _activeLifecycle?.DataClient;
if (operatorClient?.IsConnectedToGateway != true) return;
if (!OperatorScopeHelper.CanApproveDevices(operatorClient.GrantedOperatorScopes)) return;

// Track whether ANY approve succeeded so we know to schedule one
// reconnect at the end (rather than reconnecting per-entry, which
// would race with itself).
string? lastApprovedRequestId = null;

foreach (var req in info.Pending)
{
if (Interlocked.Read(ref _generation) != gen) return;
if (string.IsNullOrWhiteSpace(req.RequestId)) continue;
if (req.RequestId == _lastAutoApprovedRequestId) continue;
if (!string.Equals(req.NodeId, ownNodeId, StringComparison.OrdinalIgnoreCase)) continue;

// CAS guard scoped to JUST the approve RPC. Release before the
// post-approve reconnect so unrelated approvals are not starved
// (e.g. another own-node pending with a different requestId in
// the same or next snapshot).
if (Interlocked.CompareExchange(ref _autoApproveInFlight, req.RequestId, null) != null)
continue;

bool approved = false;
try
{
_diagnostics.Record("node", $"Operator-side auto-approving own node pairing (requestId={req.RequestId})");
try
{
approved = await operatorClient.NodePairApproveAsync(req.RequestId);
if (!approved)
_diagnostics.Record("node", "Operator-side node pair approval rejected by gateway");
}
catch (Exception ex)
{
_logger.Warn($"[ConnMgr] Operator-side node auto-approve failed: {ex.Message}");
_diagnostics.Record("node", $"Operator-side auto-approve error: {ex.Message}");
}
}
finally
{
// Always record the requestId — both on success (prevent
// re-approving the same id after the gateway re-broadcasts)
// and on failure (prevent a spin loop on a rejected id).
// Re-check generation: if a reconnect happened during the
// await above, DisposeActiveClient already cleared
// _lastAutoApprovedRequestId for the new generation; we must
// not overwrite that null with a stale id from the old gen.
if (Interlocked.Read(ref _generation) == gen)
_lastAutoApprovedRequestId = req.RequestId;
Interlocked.Exchange(ref _autoApproveInFlight, null);
}

if (approved)
{
lastApprovedRequestId = req.RequestId;
// Continue scanning so a second own-pending in the same
// snapshot (e.g. stale requestId from prior session) also
// gets attempted — broken only by an explicit failure to
// approve, which we still try the next entry for.
}
// On failure/rejection, fall through to next own-pending entry
// rather than break — the gateway may not re-broadcast if the
// approve frame round-tripped and was rejected mid-flight.
}

// Single reconnect after the snapshot is fully processed.
if (lastApprovedRequestId is not null)
{
_diagnostics.Record("node", $"Operator-side approved {lastApprovedRequestId} — reconnecting node so caps propagate");
await Task.Delay(1000);
if (Interlocked.Read(ref _generation) == gen)
await StartNodeConnectionAsync();
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/OpenClaw.Shared/IOperatorGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ public interface IOperatorGatewayClient
string? OperatorDeviceId { get; }
IReadOnlyList<string> GrantedOperatorScopes { get; }
bool IsConnectedToGateway { get; }
/// <summary>Canonical main session key resolved from hello-ok; <c>null</c> until handshake.</summary>
string? MainSessionKey { get; }
/// <summary>True once the hello-ok handshake has been processed.</summary>
bool HasHandshakeSnapshot { get; }

// ─── Connection events (from WebSocketClientBase) ───
event EventHandler<ConnectionStatus>? StatusChanged;
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Shared/OpenClaw.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<InternalsVisibleTo Include="OpenClaw.Shared.Tests" />
<InternalsVisibleTo Include="OpenClaw.Connection.Tests" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading