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
17 changes: 17 additions & 0 deletions src/OpenClaw.Shared/Capabilities/CanvasCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,23 @@ private NodeInvokeResponse HandleA2UIPush(NodeInvokeRequest request)

if (string.IsNullOrWhiteSpace(jsonl) && !string.IsNullOrWhiteSpace(jsonlPath))
{
// Validate jsonlPath to prevent arbitrary file reads.
// Resolve to absolute path and reject traversal or suspicious paths.
try
{
var fullPath = Path.GetFullPath(jsonlPath);
var tempRoot = Path.GetFullPath(Path.GetTempPath());
if (!fullPath.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"canvas.a2ui.push: jsonlPath outside temp directory: {fullPath}");
return Error("jsonlPath must be within the system temp directory");
}
}
catch (Exception ex)
{
return Error($"Invalid jsonlPath: {ex.Message}");
}

try
{
jsonl = File.ReadAllText(jsonlPath);
Expand Down
14 changes: 11 additions & 3 deletions src/OpenClaw.Shared/Capabilities/SystemCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,15 +293,23 @@ private async Task<NodeInvokeResponse> HandleRunAsync(NodeInvokeRequest request)
}
}

Logger.Info($"system.run: {command} (shell={shell ?? "auto"}, timeout={timeoutMs}ms)");
// Build the full command string for policy evaluation and logging.
// When command arrives as an argv array, we must evaluate the entire
// command line — not just argv[0] — so policy rules like "rm *" correctly
// match "rm -rf /".
var fullCommand = args != null
? FormatExecCommand(new[] { command }.Concat(args).ToArray())
: command;

Logger.Info($"system.run: {fullCommand} (shell={shell ?? "auto"}, timeout={timeoutMs}ms)");

// Check exec approval policy
if (_approvalPolicy != null)
{
var approval = _approvalPolicy.Evaluate(command, shell);
var approval = _approvalPolicy.Evaluate(fullCommand, shell);
if (!approval.Allowed)
{
Logger.Warn($"system.run DENIED: {command} ({approval.Reason})");
Logger.Warn($"system.run DENIED: {fullCommand} ({approval.Reason})");
return Error($"Command denied by exec policy: {approval.Reason}");
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public string DisplayText
{
var label = Status.ToLowerInvariant() switch
{
"ok" or "connected" or "running" => "[ON]",
"ok" or "connected" or "running" or "active" => "[ON]",
"linked" => "[LINKED]",
"ready" => "[READY]",
"connecting" or "reconnecting" => "[...]",
Expand Down
186 changes: 114 additions & 72 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public class OpenClawGatewayClient : IDisposable
private GatewayCostUsageInfo? _usageCost;
private readonly Dictionary<string, string> _pendingRequestMethods = new();
private readonly object _pendingRequestLock = new();
private readonly object _sessionsLock = new();
private readonly object _nodesLock = new();
private bool _usageStatusUnsupported;
private bool _usageCostUnsupported;
private bool _sessionPreviewUnsupported;
Expand Down Expand Up @@ -373,12 +375,24 @@ private async Task SendConnectMessageAsync(string? nonce = null)

private async Task SendRawAsync(string message)
{
if (_webSocket?.State == WebSocketState.Open)
// Capture local reference to avoid TOCTOU race with reconnect/dispose
var ws = _webSocket;
if (ws?.State != WebSocketState.Open) return;

try
{
var bytes = Encoding.UTF8.GetBytes(message);
await _webSocket.SendAsync(new ArraySegment<byte>(bytes),
await ws.SendAsync(new ArraySegment<byte>(bytes),
WebSocketMessageType.Text, true, _cts.Token);
}
catch (ObjectDisposedException)
{
// WebSocket was disposed between state check and send
}
catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.InvalidState)
{
_logger.Warn($"WebSocket send failed (state changed): {ex.Message}");
}
}

private async Task SendTrackedRequestAsync(string method, object? parameters = null)
Expand Down Expand Up @@ -990,23 +1004,37 @@ private void HandleSessionEvent(JsonElement root)

private void UpdateTrackedSession(string sessionKey, bool isMain, string? currentActivity)
{
if (!_sessions.ContainsKey(sessionKey))
SessionInfo[] snapshot;
lock (_sessionsLock)
{
_sessions[sessionKey] = new SessionInfo
if (!_sessions.ContainsKey(sessionKey))
{
Key = sessionKey,
IsMain = isMain,
Status = "active"
};
}
_sessions[sessionKey] = new SessionInfo
{
Key = sessionKey,
IsMain = isMain,
Status = "active"
};
}

_sessions[sessionKey].CurrentActivity = currentActivity;
_sessions[sessionKey].LastSeen = DateTime.UtcNow;

_sessions[sessionKey].CurrentActivity = currentActivity;
_sessions[sessionKey].LastSeen = DateTime.UtcNow;
snapshot = GetSessionListInternal();
}

SessionsUpdated?.Invoke(this, GetSessionList());
SessionsUpdated?.Invoke(this, snapshot);
}

public SessionInfo[] GetSessionList()
{
lock (_sessionsLock)
{
return GetSessionListInternal();
}
}

private SessionInfo[] GetSessionListInternal()
{
var list = new List<SessionInfo>(_sessions.Values);
list.Sort((a, b) =>
Expand Down Expand Up @@ -1096,69 +1124,75 @@ private void ParseSessions(JsonElement sessions)
{
try
{
_sessions.Clear();

// Handle both Array format and Object (dictionary) format
if (sessions.ValueKind == JsonValueKind.Array)
SessionInfo[] snapshot;
lock (_sessionsLock)
{
foreach (var item in sessions.EnumerateArray())
_sessions.Clear();

// Handle both Array format and Object (dictionary) format
if (sessions.ValueKind == JsonValueKind.Array)
{
ParseSessionItem(item);
foreach (var item in sessions.EnumerateArray())
{
ParseSessionItem(item);
}
}
}
else if (sessions.ValueKind == JsonValueKind.Object)
{
// Object format: keys are session IDs, values could be session info objects or simple strings
foreach (var prop in sessions.EnumerateObject())
else if (sessions.ValueKind == JsonValueKind.Object)
{
var sessionKey = prop.Name;
// Object format: keys are session IDs, values could be session info objects or simple strings
foreach (var prop in sessions.EnumerateObject())
{
var sessionKey = prop.Name;

// Skip metadata fields that aren't actual sessions
if (sessionKey is "recent" or "count" or "path" or "defaults" or "ts")
continue;
// Skip metadata fields that aren't actual sessions
if (sessionKey is "recent" or "count" or "path" or "defaults" or "ts")
continue;

// Skip non-session keys (must look like a session key pattern)
if (!sessionKey.Equals("global", StringComparison.OrdinalIgnoreCase) &&
!sessionKey.Contains(':') &&
!sessionKey.Contains("agent") &&
!sessionKey.Contains("session"))
continue;
// Skip non-session keys (must look like a session key pattern)
if (!sessionKey.Equals("global", StringComparison.OrdinalIgnoreCase) &&
!sessionKey.Contains(':') &&
!sessionKey.Contains("agent") &&
!sessionKey.Contains("session"))
continue;

var session = new SessionInfo { Key = sessionKey };
var item = prop.Value;
var session = new SessionInfo { Key = sessionKey };
var item = prop.Value;

// Detect main session from key pattern - "agent:main:main" ends with ":main"
var endsWithMain = sessionKey.EndsWith(":main");
session.IsMain = sessionKey == "main" || endsWithMain || sessionKey.Contains(":main:main");
_logger.Debug($"Session key={sessionKey}, endsWithMain={endsWithMain}, IsMain={session.IsMain}");
// Detect main session from key pattern - "agent:main:main" ends with ":main"
var endsWithMain = sessionKey.EndsWith(":main");
session.IsMain = sessionKey == "main" || endsWithMain || sessionKey.Contains(":main:main");
_logger.Debug($"Session key={sessionKey}, endsWithMain={endsWithMain}, IsMain={session.IsMain}");

// Value might be an object with session details or just a string status
if (item.ValueKind == JsonValueKind.Object)
{
// Only override IsMain if the JSON explicitly says true
if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
session.IsMain = true;
PopulateSessionFromObject(session, item);
}
else if (item.ValueKind == JsonValueKind.String)
{
// Simple string value - skip if it looks like a path (metadata)
var strVal = item.GetString() ?? "";
if (strVal.StartsWith("/") || strVal.Contains("/."))
// Value might be an object with session details or just a string status
if (item.ValueKind == JsonValueKind.Object)
{
// Only override IsMain if the JSON explicitly says true
if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
session.IsMain = true;
PopulateSessionFromObject(session, item);
}
else if (item.ValueKind == JsonValueKind.String)
{
// Simple string value - skip if it looks like a path (metadata)
var strVal = item.GetString() ?? "";
if (strVal.StartsWith("/") || strVal.Contains("/."))
continue;
session.Status = strVal;
}
else if (item.ValueKind == JsonValueKind.Number)
{
// Skip numeric values (like count)
continue;
session.Status = strVal;
}
else if (item.ValueKind == JsonValueKind.Number)
{
// Skip numeric values (like count)
continue;
}
}

_sessions[session.Key] = session;
_sessions[session.Key] = session;
}
}

snapshot = GetSessionListInternal();
}

SessionsUpdated?.Invoke(this, GetSessionList());
SessionsUpdated?.Invoke(this, snapshot);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -1308,9 +1342,12 @@ private void ParseNodeList(JsonElement nodesPayload)
.ThenBy(n => n.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();

_nodes.Clear();
foreach (var node in ordered)
_nodes[node.NodeId] = node;
lock (_nodesLock)
{
_nodes.Clear();
foreach (var node in ordered)
_nodes[node.NodeId] = node;
}

NodesUpdated?.Invoke(this, ordered);
}
Expand Down Expand Up @@ -1704,13 +1741,18 @@ private static string TruncateLabel(string text, int maxLen = 60)

public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_cts.Cancel();
ClearPendingRequests();
_webSocket?.Dispose();
_cts.Dispose();
}
if (_disposed) return;
_disposed = true;

try { _cts.Cancel(); } catch { }

ClearPendingRequests();

var ws = _webSocket;
_webSocket = null;
try { ws?.Dispose(); } catch { }

// Don't dispose _cts immediately — listen loop or reconnect may still reference it.
// It will be GC'd after all pending tasks complete.
}
}
16 changes: 11 additions & 5 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@
StartDeepLinkServer();

// Register global hotkey if enabled
if (_settings.GlobalHotkeyEnabled)

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-arm64)

Dereference of a possibly null reference.

Check warning on line 284 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-x64)

Dereference of a possibly null reference.
{
_globalHotkey = new GlobalHotkeyService();
_globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed;
Expand Down Expand Up @@ -1603,15 +1603,21 @@

private void OnSettingsSaved(object? sender, EventArgs e)
{
// Reconnect with new settings
// Reconnect with new settings — mirror the startup if/else pattern
// to avoid dual connections that cause gateway conflicts.
_gatewayClient?.Dispose();
InitializeGatewayClient();

// Reinitialize node service (safe dispose pattern)
var oldNodeService = _nodeService;
_nodeService = null;
try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); }
InitializeNodeService();

if (_settings?.EnableNodeMode == true)
{
InitializeNodeService();
}
else
{
InitializeGatewayClient();
}

// Update global hotkey
if (_settings!.GlobalHotkeyEnabled)
Expand Down
8 changes: 6 additions & 2 deletions src/OpenClaw.Tray.WinUI/Services/NodeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ private async Task<string> OnCanvasEval(string script)
{
var tcs = new TaskCompletionSource<string>();

_dispatcherQueue.TryEnqueue(async () =>
bool enqueued = _dispatcherQueue.TryEnqueue(async () =>
{
try
{
Expand All @@ -269,6 +269,8 @@ private async Task<string> OnCanvasEval(string script)
tcs.SetException(ex);
}
});
if (!enqueued)
tcs.TrySetException(new InvalidOperationException("Dispatcher queue unavailable"));

return await tcs.Task;
}
Expand All @@ -277,7 +279,7 @@ private async Task<string> OnCanvasSnapshot(CanvasSnapshotArgs args)
{
var tcs = new TaskCompletionSource<string>();

_dispatcherQueue.TryEnqueue(async () =>
bool enqueued = _dispatcherQueue.TryEnqueue(async () =>
{
try
{
Expand All @@ -296,6 +298,8 @@ private async Task<string> OnCanvasSnapshot(CanvasSnapshotArgs args)
tcs.SetException(ex);
}
});
if (!enqueued)
tcs.TrySetException(new InvalidOperationException("Dispatcher queue unavailable"));

return await tcs.Task;
}
Expand Down
Loading
Loading