diff --git a/src/OpenClaw.Connection/ConnectionDiagnostics.cs b/src/OpenClaw.Connection/ConnectionDiagnostics.cs index f3ed8fadc..e727e8a16 100644 --- a/src/OpenClaw.Connection/ConnectionDiagnostics.cs +++ b/src/OpenClaw.Connection/ConnectionDiagnostics.cs @@ -27,7 +27,11 @@ public ConnectionDiagnostics(int capacity = 500, IClock? clock = null) public void Record(string category, string message, string? detail = null) { - var evt = new ConnectionDiagnosticEvent(_clock.UtcNow, category, message, detail); + var evt = new ConnectionDiagnosticEvent( + _clock.UtcNow, + TokenSanitizer.SanitizeLogMessage(category), + TokenSanitizer.SanitizeLogMessage(message), + detail is null ? null : TokenSanitizer.SanitizeLogMessage(detail)); lock (_lock) { _buffer[_head] = evt; diff --git a/src/OpenClaw.SetupEngine/SetupLogger.cs b/src/OpenClaw.SetupEngine/SetupLogger.cs index a8b36a5dc..397761b2d 100644 --- a/src/OpenClaw.SetupEngine/SetupLogger.cs +++ b/src/OpenClaw.SetupEngine/SetupLogger.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; +using OpenClaw.Shared; namespace OpenClaw.SetupEngine; @@ -84,7 +87,7 @@ private void Write(LogLevel level, string message, object? data = null) { if (level < _minLevel) return; - var sanitizedMessage = Sanitize(message); + var sanitizedMessage = NormalizeLogString(Sanitize(message)); var entry = new LogEntry(DateTimeOffset.UtcNow, _runId, level, sanitizedMessage, SanitizeData(data)); _recentEntries.Enqueue(entry); while (_recentEntries.Count > MaxRecentEntries) @@ -125,6 +128,10 @@ private void Write(LogLevel level, string message, object? data = null) internal static string Sanitize(string input) { if (string.IsNullOrEmpty(input)) return input; + input = TokenSanitizer.SanitizeLogMessage(input); + if (input == TokenSanitizer.SanitizerTimeoutSentinel) + return input; + input = PrivateKeyPattern().Replace(input, "[REDACTED-PRIVATE-KEY]"); input = BearerPattern().Replace(input, "$1[REDACTED]"); input = JwtPattern().Replace(input, "[REDACTED-JWT]"); @@ -140,21 +147,65 @@ internal static string Sanitize(string input) return null; if (data is string value) - return Sanitize(value); + return NormalizeLogString(Sanitize(value)); try { var json = JsonSerializer.Serialize(data, _jsonOptions); var sanitized = Sanitize(json); - using var doc = JsonDocument.Parse(sanitized); - return doc.RootElement.Clone(); + var node = JsonNode.Parse(sanitized); + return NormalizeJsonNode(node); } catch (Exception ex) when (ex is JsonException or NotSupportedException or ArgumentException) { - return Sanitize(data.ToString() ?? string.Empty); + return NormalizeLogString(Sanitize(data.ToString() ?? string.Empty)); } } + private static JsonNode? NormalizeJsonNode(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var normalizedObject = new JsonObject(); + foreach (var property in obj) + normalizedObject[property.Key] = NormalizeJsonNode(property.Value); + return normalizedObject; + + case JsonArray array: + var normalizedArray = new JsonArray(); + for (var i = 0; i < array.Count; i++) + normalizedArray.Add(NormalizeJsonNode(array[i])); + return normalizedArray; + + case JsonValue value when value.TryGetValue(out var text): + var normalized = NormalizeLogString(text); + var trimmed = normalized.TrimStart(); + if (trimmed.StartsWith('{') || trimmed.StartsWith('[')) + { + try + { + return NormalizeJsonNode(JsonNode.Parse(normalized)); + } + catch (JsonException) + { + // Keep malformed JSON-shaped text as a flattened string. + } + } + + return JsonValue.Create(normalized); + + default: + return node?.DeepClone(); + } + } + + private static string NormalizeLogString(string value) => + value + .Replace("\r\n", " ", StringComparison.Ordinal) + .Replace('\r', ' ') + .Replace('\n', ' '); + private static string Truncate(string input, int max = 4096) => input.Length <= max ? input : input[..max] + $"... [truncated {input.Length - max} chars]"; @@ -178,7 +229,8 @@ private static string Truncate(string input, int max = 4096) private static readonly JsonSerializerOptions _jsonOptions = new() { - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; public void Dispose() => _writer?.Dispose(); diff --git a/src/OpenClaw.Shared/TokenSanitizer.cs b/src/OpenClaw.Shared/TokenSanitizer.cs index 9fadb9658..fa590d96b 100644 --- a/src/OpenClaw.Shared/TokenSanitizer.cs +++ b/src/OpenClaw.Shared/TokenSanitizer.cs @@ -10,7 +10,58 @@ namespace OpenClaw.Shared; public static class TokenSanitizer { - private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); + + private static readonly string[] SensitiveKeyFragments = + [ + "authorization", + "api-key", + "apikey", + "bearer", + "bot-token", + "bottoken", + "browser-password", + "browserpassword", + "client-secret", + "clientsecret", + "cookie", + "device-id", + "deviceid", + "dpapi", + "identity", + "jwt", + "nonce", + "node", + "nsec", + "openclawid", + "password", + "private-key", + "privatekey", + "raw-error-response", + "raw_error_response", + "relay-url", + "relayurl", + "secret", + "session-key", + "sessionkey", + "setup-code", + "setupcode", + "signing", + "token", + "webhook", + "x-api-key", + "xapikey" + ]; + + private static readonly string[] SensitiveHeaders = + [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", + "x-openclaw-token" + ]; private static readonly Regex AuthorizationBearerPattern = new( @"(?i)(Authorization\s*:\s*Bearer\s+)([^\s""',;]+)", @@ -42,11 +93,26 @@ public static class TokenSanitizer RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, RegexTimeout); + private static readonly Regex PathWindowsUserForwardSlashPattern = new( + @"\b[A-Za-z]:/{1,4}Users/{1,4}[^/\s]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RegexTimeout); + + private static readonly Regex PathWslMountedWindowsUserPattern = new( + @"/mnt/[a-z]/Users/[^/\s]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + RegexTimeout); + private static readonly Regex PathUnixUserPattern = new( @"/Users/[^/\s]+", RegexOptions.Compiled | RegexOptions.CultureInvariant, RegexTimeout); + private static readonly Regex PathLinuxHomeUserPattern = new( + @"/home/[^/\s]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + RegexTimeout); + // Captures the scheme separately so the replacement can drop any user:pass@ userinfo entirely // rather than leaving credentials adjacent to the redacted . The match is greedy through // path/query/fragment up to common log delimiters (whitespace, quote, backtick, angle brackets, @@ -141,14 +207,22 @@ public static string SanitizeLogMessage(string? message) try { + sanitized = RedactPrivateKeyBlocks(sanitized); + sanitized = RedactSignedHandshakeLines(sanitized); + sanitized = RedactDpapiBlobs(sanitized); + sanitized = RedactAgentSessionKeys(sanitized); + sanitized = RedactSensitiveCommandOptions(sanitized); + sanitized = RedactSensitiveKeyValues(sanitized); + sanitized = RedactGuidTokens(sanitized); sanitized = RedactLocalPaths(sanitized); // Reconstruct the URL as scheme://[:port]/[/…] so credential-bearing // userinfo, query strings, fragments, and deeper path segments are dropped. Mirrors the // UrlLogSanitizer contract used elsewhere for disk-backed log safety. sanitized = UrlHostPattern.Replace(sanitized, RedactUrlMatch); + sanitized = EmailPattern.Replace(sanitized, ""); + sanitized = RedactLocalIdentityNames(sanitized); sanitized = IpV6Pattern.Replace(sanitized, RedactIfValidIpV6); sanitized = IpAddressPattern.Replace(sanitized, ""); - sanitized = EmailPattern.Replace(sanitized, ""); sanitized = UserAtHostPattern.Replace(sanitized, "@"); sanitized = HostAfterToPattern.Replace(sanitized, ""); return LeadingHostPattern.Replace(sanitized, ""); @@ -159,6 +233,242 @@ public static string SanitizeLogMessage(string? message) } } + private static string RedactPrivateKeyBlocks(string text) + { + const string beginMarker = "-----BEGIN "; + const string endPrefix = "-----END "; + const string endSuffix = "-----"; + + var builder = new System.Text.StringBuilder(text.Length); + var index = 0; + while (index < text.Length) + { + var begin = text.IndexOf(beginMarker, index, StringComparison.OrdinalIgnoreCase); + if (begin < 0) + { + builder.Append(text, index, text.Length - index); + break; + } + + var beginLineEnd = text.IndexOf(endSuffix, begin + beginMarker.Length, StringComparison.Ordinal); + if (beginLineEnd < 0 || + !text.AsSpan(begin, beginLineEnd + endSuffix.Length - begin).Contains("PRIVATE KEY", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(text, index, begin + beginMarker.Length - index); + index = begin + beginMarker.Length; + continue; + } + + var end = text.IndexOf(endPrefix, beginLineEnd + endSuffix.Length, StringComparison.OrdinalIgnoreCase); + if (end < 0) + { + builder.Append(text, index, begin - index); + builder.Append("[REDACTED_PRIVATE_KEY]"); + break; + } + + var endLineEnd = text.IndexOf(endSuffix, end + endPrefix.Length, StringComparison.Ordinal); + builder.Append(text, index, begin - index); + builder.Append("[REDACTED_PRIVATE_KEY]"); + index = endLineEnd < 0 ? text.Length : endLineEnd + endSuffix.Length; + } + + return builder.ToString(); + } + + private static string RedactSignedHandshakeLines(string text) + { + const string marker = "signed:"; + var builder = new System.Text.StringBuilder(text.Length); + var index = 0; + while (index < text.Length) + { + var start = text.IndexOf(marker, index, StringComparison.OrdinalIgnoreCase); + if (start < 0) + { + builder.Append(text, index, text.Length - index); + break; + } + + var lineEnd = FindLineEnd(text, start); + builder.Append(text, index, start - index); + builder.Append("signed: [REDACTED_HANDSHAKE]"); + builder.Append(text, lineEnd, FindLineBreakLength(text, lineEnd)); + index = lineEnd + FindLineBreakLength(text, lineEnd); + } + + return builder.ToString(); + } + + private static string RedactDpapiBlobs(string text) => + RedactPrefixedToken(text, "dpapi:", "dpapi:[REDACTED]"); + + private static string RedactAgentSessionKeys(string text) => + RedactPrefixedToken(text, "agent:", "[REDACTED_SESSION_KEY]"); + + private static string RedactPrefixedToken(string text, string prefix, string replacement) + { + var builder = new System.Text.StringBuilder(text.Length); + var index = 0; + while (index < text.Length) + { + var start = text.IndexOf(prefix, index, StringComparison.OrdinalIgnoreCase); + if (start < 0) + { + builder.Append(text, index, text.Length - index); + break; + } + + var end = start + prefix.Length; + while (end < text.Length && !IsValueTerminator(text[end]) && !IsQuote(text[end])) + end++; + + builder.Append(text, index, start - index); + builder.Append(replacement); + index = end; + } + + return builder.ToString(); + } + + private static string RedactSensitiveCommandOptions(string text) + { + var builder = new System.Text.StringBuilder(text.Length); + var index = 0; + while (index < text.Length) + { + var optionStart = text.IndexOf("--", index, StringComparison.Ordinal); + if (optionStart < 0) + { + builder.Append(text, index, text.Length - index); + break; + } + + var optionEnd = optionStart + 2; + while (optionEnd < text.Length && IsKeyChar(text[optionEnd])) + optionEnd++; + + var optionName = text[(optionStart + 2)..optionEnd]; + if (!IsSensitiveKey(optionName)) + { + builder.Append(text, index, optionEnd - index); + index = optionEnd; + continue; + } + + var valueStart = optionEnd; + while (valueStart < text.Length && char.IsWhiteSpace(text[valueStart])) + valueStart++; + + if (valueStart >= text.Length || IsValueTerminator(text[valueStart])) + { + builder.Append(text, index, valueStart - index); + index = valueStart; + continue; + } + + var (valueContentStart, valueEnd) = FindValueSpan(text, valueStart); + if (IsAlreadyRedacted(text, valueContentStart, valueEnd)) + { + builder.Append(text, index, valueEnd - index); + index = valueEnd; + continue; + } + + builder.Append(text, index, valueContentStart - index); + builder.Append("[REDACTED]"); + if (valueEnd < text.Length && IsQuote(text[valueEnd])) + { + builder.Append(text[valueEnd]); + valueEnd++; + } + + index = valueEnd; + } + + return builder.ToString(); + } + + private static string RedactSensitiveKeyValues(string text) + { + var builder = new System.Text.StringBuilder(text.Length); + var index = 0; + while (index < text.Length) + { + var delimiter = FindNextKeyValueDelimiter(text, index); + if (delimiter < 0) + { + builder.Append(text, index, text.Length - index); + break; + } + + var keyStart = FindKeyStart(text, delimiter - 1); + var key = NormalizeKey(text[keyStart..delimiter]); + var isSensitiveHeader = IsSensitiveHeader(key); + if (!IsSensitiveKey(key) && !isSensitiveHeader) + { + builder.Append(text, index, delimiter + 1 - index); + index = delimiter + 1; + continue; + } + + var valueStart = delimiter + 1; + while (valueStart < text.Length && char.IsWhiteSpace(text[valueStart])) + valueStart++; + + if (valueStart >= text.Length) + { + builder.Append(text, index, valueStart - index); + index = valueStart; + continue; + } + + var (valueContentStart, valueEnd) = isSensitiveHeader + ? (valueStart, FindLineEnd(text, valueStart)) + : FindValueSpan(text, valueStart); + if (IsAlreadyRedacted(text, valueContentStart, valueEnd)) + { + builder.Append(text, index, valueEnd - index); + index = valueEnd; + continue; + } + + builder.Append(text, index, valueContentStart - index); + builder.Append("[REDACTED]"); + if (valueEnd < text.Length && IsQuote(text[valueEnd])) + { + builder.Append(text[valueEnd]); + valueEnd++; + } + + index = valueEnd; + } + + return builder.ToString(); + } + + private static string RedactGuidTokens(string text) + { + var builder = new System.Text.StringBuilder(text.Length); + var index = 0; + while (index < text.Length) + { + if (!IsGuidCandidateStart(text, index) || + index + 36 > text.Length || + !Guid.TryParse(text.AsSpan(index, 36), out _)) + { + builder.Append(text[index]); + index++; + continue; + } + + builder.Append("[REDACTED_ID]"); + index += 36; + } + + return builder.ToString(); + } + // Validates an IPv6 regex match with System.Net.IPAddress so non-IPv6 substrings // that happen to fit the pattern (e.g. [1:2:3:4:5]) are left intact rather than // redacted to the misleading marker. Textual zone-ids (fe80::1%eth0) are @@ -255,9 +565,156 @@ private static string RedactLocalPaths(string message) } redacted = PathWindowsUserPattern.Replace(redacted, "%USERPROFILE%"); - return PathUnixUserPattern.Replace(redacted, "$HOME"); + redacted = PathWindowsUserForwardSlashPattern.Replace(redacted, "%USERPROFILE%"); + redacted = PathWslMountedWindowsUserPattern.Replace(redacted, "%USERPROFILE%"); + redacted = PathUnixUserPattern.Replace(redacted, "$HOME"); + return PathLinuxHomeUserPattern.Replace(redacted, "$HOME"); + } + + private static string RedactLocalIdentityNames(string message) + { + var redacted = message; + redacted = RedactKnownIdentityName(redacted, Environment.UserName, ""); + redacted = RedactKnownIdentityName(redacted, Environment.MachineName, ""); + return redacted; + } + + private static string RedactKnownIdentityName(string text, string? value, string replacement) + { + if (string.IsNullOrWhiteSpace(value) || value.Length < 3) + return text; + + var normalized = value.Trim(); + if (normalized.Equals("user", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("admin", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("desktop", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + return text; + } + + return Regex.Replace( + text, + $@"(? 0 && IsKeyCharOrQuote(text[i - 1])) + return i; + } + + return -1; } + private static int FindKeyStart(string text, int index) + { + while (index >= 0 && IsKeyCharOrQuote(text[index])) + index--; + return index + 1; + } + + private static string NormalizeKey(string key) => + key.Trim().Trim('"', '\'').Replace("_", "-", StringComparison.Ordinal).ToLowerInvariant(); + + private static bool IsSensitiveKey(string key) + { + var normalized = NormalizeKey(key); + if (normalized == "id" || normalized.EndsWith("-id", StringComparison.Ordinal)) + return true; + + foreach (var fragment in SensitiveKeyFragments) + { + if (normalized.Contains(fragment, StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static bool IsSensitiveHeader(string key) + { + var normalized = NormalizeKey(key); + foreach (var header in SensitiveHeaders) + { + if (string.Equals(normalized, header, StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static (int ContentStart, int End) FindValueSpan(string text, int valueStart) + { + if (valueStart >= text.Length) + return (valueStart, valueStart); + + if (text.AsSpan(valueStart).StartsWith("[REDACTED", StringComparison.Ordinal)) + { + var markerEnd = text.IndexOf(']', valueStart); + if (markerEnd >= 0) + return (valueStart, markerEnd + 1); + } + + if (IsQuote(text[valueStart])) + { + var quote = text[valueStart]; + var endQuote = valueStart + 1; + while (endQuote < text.Length && text[endQuote] != quote) + endQuote++; + return (valueStart + 1, endQuote); + } + + var end = valueStart; + while (end < text.Length && !IsValueTerminator(text[end])) + end++; + return (valueStart, end); + } + + private static bool IsAlreadyRedacted(string text, int valueContentStart, int valueEnd) => + valueContentStart < valueEnd && + text.AsSpan(valueContentStart, valueEnd - valueContentStart).Contains("[REDACTED", StringComparison.Ordinal); + + private static int FindLineEnd(string text, int start) + { + var index = start; + while (index < text.Length && text[index] != '\r' && text[index] != '\n') + index++; + return index; + } + + private static int FindLineBreakLength(string text, int lineEnd) + { + if (lineEnd >= text.Length) + return 0; + if (text[lineEnd] == '\r' && lineEnd + 1 < text.Length && text[lineEnd + 1] == '\n') + return 2; + return 1; + } + + private static bool IsGuidCandidateStart(string text, int index) => + (index == 0 || !IsHexOrDash(text[index - 1])) && IsHex(text[index]); + + private static bool IsHexOrDash(char c) => IsHex(c) || c == '-'; + + private static bool IsHex(char c) => + c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; + + private static bool IsKeyCharOrQuote(char c) => IsKeyChar(c) || IsQuote(c); + + private static bool IsKeyChar(char c) => + char.IsLetterOrDigit(c) || c is '_' or '-' or '.'; + + private static bool IsQuote(char c) => c is '"' or '\''; + + private static bool IsValueTerminator(char c) => + char.IsWhiteSpace(c) || c is ',' or ';' or '}' or ']'; + private static IEnumerable<(string Folder, string Replacement)> KnownLocalFolders() { yield return (Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "%USERPROFILE%"); diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index e62492aa5..6f40c1cd0 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3275,6 +3275,9 @@ private void UpdateStatusDetailWindow() internal GatewayCommandCenterState BuildCommandCenterState() => new CommandCenterStateBuilder(CaptureSnapshot()).Build(); + internal IReadOnlyList GetConnectionDiagnosticEvents() => + _connectionManager?.Diagnostics.GetRecent(200) ?? []; + private AppStateSnapshot CaptureSnapshot() { var activeGateway = _gatewayRegistry?.GetActive(); diff --git a/src/OpenClaw.Tray.WinUI/AppLogger.cs b/src/OpenClaw.Tray.WinUI/AppLogger.cs index c8006a385..a8cb154bd 100644 --- a/src/OpenClaw.Tray.WinUI/AppLogger.cs +++ b/src/OpenClaw.Tray.WinUI/AppLogger.cs @@ -9,6 +9,6 @@ internal sealed class AppLogger : IOpenClawLogger public void Debug(string message) => Logger.Debug(message); public void Warn(string message) => Logger.Warn(message); public void Error(string message, Exception? ex = null) => - Logger.Error(ex != null ? $"{message}: {ex.Message}" : message); + Logger.Error(ex != null ? $"{message}: {ex}" : message); public void Trace(string message) => Logger.Trace(message); } diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index c318304df..743e49a1b 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.IO; using System.Linq; +using System.Text.Encodings.Web; using System.Text.Json; using OpenClaw.Chat; using OpenClaw.Shared; @@ -67,6 +68,12 @@ internal static class LocalizationHelper public sealed class OpenClawChatDataProvider : IChatDataProvider { private const long ResetTimestampToleranceMs = 1000; + private static readonly JsonSerializerOptions CacheJsonOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + /// /// Process-wide cache mapping an attachment's filename to its raw image /// bytes. Populated by for image @@ -86,6 +93,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly string _attachmentMetaCacheFilePath; private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes private long _toolMetaSaveVersion; + private bool _toolMetaCacheDirty; private readonly Dictionary _timelines = new(); private readonly Dictionary _localInlineApprovals = new(StringComparer.Ordinal); private readonly Dictionary _activeRunIds = new(); // sessionKey → runId @@ -3301,7 +3309,10 @@ private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string threadId saveAttachmentMeta = _attachmentMetaCache.Remove(threadId) || saveAttachmentMeta; if (saveToolMeta) + { + _toolMetaCacheDirty = true; _toolMetaSaveVersion++; + } if (_activeRunIds.TryGetValue(threadId, out var activeRunId) && !string.IsNullOrEmpty(activeRunId)) AddResetIgnoredRunIdLocked(threadId, activeRunId); @@ -4232,6 +4243,14 @@ private static Dictionary> LoadToolMetaCache(string return new(); var json = File.ReadAllText(cacheFilePath); var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); + if (dict is not null) + { + foreach (var entry in dict.Values.SelectMany(entries => entries)) + { + entry.ToolName = NormalizeCachedDisplayText(entry.ToolName); + entry.Label = NormalizeCachedDisplayText(entry.Label); + } + } return dict ?? new(); } catch (Exception ex) @@ -4249,6 +4268,15 @@ private static Dictionary> LoadAttachmentMeta return new(); var json = File.ReadAllText(cacheFilePath); var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); + if (dict is not null) + { + foreach (var entry in dict.Values.SelectMany(entries => entries)) + { + entry.Text = NormalizeCachedDisplayText(entry.Text); + foreach (var attachment in entry.Attachments) + attachment.FileName = NormalizeCachedDisplayText(attachment.FileName); + } + } return dict ?? new(); } catch (Exception ex) @@ -4270,10 +4298,10 @@ private void SaveAttachmentMetaCache() kv => kv.Value.Select(e => new CachedAttachmentMeta { Ts = e.Ts, - Text = e.Text, + Text = NormalizeCachedDisplayText(e.Text), Attachments = e.Attachments.Select(a => new CachedAttachmentItem { - FileName = a.FileName, + FileName = NormalizeCachedDisplayText(a.FileName), IsImage = a.IsImage }).ToList() }).ToList(), @@ -4290,8 +4318,7 @@ private void SaveAttachmentMetaCache() foreach (var k in toRemove) snapshot.Remove(k); } - var json = System.Text.Json.JsonSerializer.Serialize(snapshot, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var json = System.Text.Json.JsonSerializer.Serialize(snapshot, CacheJsonOptions); lock (_attachmentMetaSaveGate) { @@ -4339,7 +4366,7 @@ private void CacheAttachmentMeta( .Where(a => !string.IsNullOrWhiteSpace(a.FileName)) .Select(a => new CachedAttachmentItem { - FileName = a.FileName, + FileName = NormalizeCachedDisplayText(a.FileName), IsImage = string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase) }) .ToList(); @@ -4367,7 +4394,7 @@ private void CacheAttachmentMeta( list.Add(new CachedAttachmentMeta { Ts = tsMs, - Text = TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(text)), + Text = NormalizeCachedDisplayText(TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(text))), Attachments = items }); @@ -4400,10 +4427,10 @@ private static List CloneAttachmentMeta(List new CachedAttachmentMeta { Ts = e.Ts, - Text = e.Text, + Text = NormalizeCachedDisplayText(e.Text), Attachments = e.Attachments.Select(a => new CachedAttachmentItem { - FileName = a.FileName, + FileName = NormalizeCachedDisplayText(a.FileName), IsImage = a.IsImage }).ToList() }).ToList(); @@ -4499,14 +4526,16 @@ private void SaveToolMetaCache(long? expectedVersion = null) { if (expectedVersion is long version && (version != _toolMetaSaveVersion || _disposed)) return; + if (!_toolMetaCacheDirty) + return; snapshot = _toolMetaCache.ToDictionary( kv => kv.Key, kv => kv.Value.Select(e => new CachedToolMeta { Ts = e.Ts, - ToolName = e.ToolName, - Label = e.Label + ToolName = NormalizeCachedDisplayText(e.ToolName), + Label = NormalizeCachedDisplayText(e.Label) }).ToList(), StringComparer.Ordinal); } @@ -4522,8 +4551,7 @@ private void SaveToolMetaCache(long? expectedVersion = null) foreach (var k in toRemove) snapshot.Remove(k); } - var json = System.Text.Json.JsonSerializer.Serialize(snapshot, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var json = System.Text.Json.JsonSerializer.Serialize(snapshot, CacheJsonOptions); lock (_toolMetaSaveGate) { @@ -4545,6 +4573,7 @@ private void SaveToolMetaCache(long? expectedVersion = null) { File.WriteAllText(tempPath, json); File.Move(tempPath, _toolMetaCacheFilePath, overwrite: true); + MarkToolMetaCacheSaved(expectedVersion); } finally { @@ -4591,7 +4620,12 @@ internal void CacheToolMeta(string threadId, long tsMs, string toolName, string if (list.Count > 0 && list[^1].Ts == tsMs && list[^1].ToolName == toolName) return; - list.Add(new CachedToolMeta { Ts = tsMs, ToolName = toolName, Label = label }); + list.Add(new CachedToolMeta + { + Ts = tsMs, + ToolName = NormalizeCachedDisplayText(toolName), + Label = NormalizeCachedDisplayText(label) + }); // Cap per-session entries if (list.Count > MaxToolEntriesPerSession) @@ -4599,6 +4633,7 @@ internal void CacheToolMeta(string threadId, long tsMs, string toolName, string // Debounce save — reset the timer on each cache addition so we only // write once after 500ms of quiescence, avoiding concurrent file writes. + _toolMetaCacheDirty = true; saveVersion = ++_toolMetaSaveVersion; timerToDispose = _toolMetaSaveTimer; _toolMetaSaveTimer = new System.Threading.Timer(_ => SaveToolMetaCache(saveVersion), null, 500, Timeout.Infinite); @@ -4654,7 +4689,30 @@ internal void CacheToolMeta(string threadId, long tsMs, string toolName, string if (historyTsMs > 0 && candidate.Ts > 0 && candidate.Ts > historyTsMs + 300_000) return null; // cached entry is >5 min after this history entry — not a match - return cache.Dequeue(); + var match = cache.Dequeue(); + match.ToolName = NormalizeCachedDisplayText(match.ToolName); + match.Label = NormalizeCachedDisplayText(match.Label); + return match; + } + + private static string NormalizeCachedDisplayText(string? value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return value + .Replace("\r\n", " ", StringComparison.Ordinal) + .Replace('\r', ' ') + .Replace('\n', ' '); + } + + private void MarkToolMetaCacheSaved(long? savedVersion) + { + lock (_gate) + { + if (savedVersion is null || savedVersion == _toolMetaSaveVersion) + _toolMetaCacheDirty = false; + } } /// diff --git a/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs index 3bcb2fc8a..a26645c35 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/CommandCenterTextHelper.cs @@ -12,9 +12,6 @@ namespace OpenClawTray.Helpers; internal static class CommandCenterTextHelper { - private const int RecentTrayLogTailLines = 120; - private const int RecentTrayLogMaxChars = 24_000; - // Pre-compiled patterns used in RedactSupportPath / RedactSupportValue. // Compiled once at startup; reused on every diagnostic / support-text build. private static readonly Regex PathWindowsUserPattern = new( @@ -27,40 +24,6 @@ internal static class CommandCenterTextHelper RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); - private static readonly Regex ValueUrlHostPattern = new( - @"\b(?[a-z][a-z0-9+.-]*)://(?:[^@\s/]+@)?(?\[[^\]\s]+\]|[^:/\s]+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); - - private static readonly Regex ValueIpPattern = new( - @"\b(?:\d{1,3}\.){3}\d{1,3}\b", - RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); - - // IPv6 regex is shared with TokenSanitizer to keep log sanitization and support-context - // redaction in lock-step. See TokenSanitizer.IpV6Pattern for the alternative-by-alternative - // breakdown and the rationale for the trailing negative lookahead. - - private static readonly Regex ValueEmailPattern = new( - @"\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); - - private static readonly Regex ValueUserAtHostPattern = new( - @"\b(?[A-Za-z0-9._-]+)@(?[A-Za-z0-9._-]+)(?=[:\s]|$)", - RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); - - private static readonly Regex ValueHostAfterToPattern = new( - @"(?<=\bto\s)[A-Za-z0-9._-]+(?=:\d{1,5}\b)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); - - private static readonly Regex ValueLeadingHostPattern = new( - @"^\s*[A-Za-z0-9._-]+(?=:\d{1,5}\b)", - RegexOptions.Compiled, - TimeSpan.FromMilliseconds(100)); - internal static string BuildSupportContext(GatewayCommandCenterState state) { var builder = new StringBuilder(); @@ -128,7 +91,6 @@ internal static string BuildDebugBundle(GatewayCommandCenterState state) AppendSection(builder, "Channel Summary", BuildChannelSummaryText(state.Channels)); AppendSection(builder, "Activity Summary", BuildActivitySummary(state.RecentActivity)); AppendSection(builder, "Extensibility Summary", BuildExtensibilitySummary(state.Channels)); - AppendSection(builder, "Recent Tray Log", BuildRecentTrayLogTail(Logger.LogFilePath)); return builder.ToString(); } @@ -357,61 +319,6 @@ private static void AppendSection(StringBuilder builder, string title, string co builder.AppendLine(); } - private static string BuildRecentTrayLogTail(string? logPath) - { - if (string.IsNullOrWhiteSpace(logPath)) - return "Tray log path is not configured."; - - if (!File.Exists(logPath)) - return $"Tray log does not exist: {RedactSupportPath(logPath)}"; - - var lines = new Queue(RecentTrayLogTailLines); - try - { - using var stream = new FileStream(logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - using var reader = new StreamReader(stream); - while (reader.ReadLine() is { } line) - { - lines.Enqueue(RedactSupportLogLine(line)); - while (lines.Count > RecentTrayLogTailLines) - lines.Dequeue(); - } - } - catch (IOException ex) - { - return $"Unable to read tray log '{RedactSupportPath(logPath)}': {RedactSupportLogLine(ex.Message)}"; - } - catch (UnauthorizedAccessException ex) - { - return $"Unable to read tray log '{RedactSupportPath(logPath)}': {RedactSupportLogLine(ex.Message)}"; - } - - if (lines.Count == 0) - return $"Tray log is empty: {RedactSupportPath(logPath)}"; - - var builder = new StringBuilder(); - builder.AppendLine($"Source: {RedactSupportPath(logPath)}"); - builder.AppendLine($"Showing the last {lines.Count} lines. Sensitive values are redacted before writing and again before bundling."); - foreach (var line in lines) - { - if (builder.Length >= RecentTrayLogMaxChars) - { - builder.AppendLine("... truncated ..."); - break; - } - - builder.AppendLine(line); - } - - return builder.ToString(); - } - - // SanitizeLogMessage already performs the same folder + Windows/Unix user-path redactions - // that an earlier version of this helper duplicated here. Delegating avoids redundant - // allocations on every diagnostic-bundle line. - private static string RedactSupportLogLine(string line) - => TokenSanitizer.SanitizeLogMessage(line); - private static string BuildBrowserProxySshForwardHint(int browserProxyPort, TunnelCommandCenterInfo? tunnel) { if (browserProxyPort is < 1 or > 65535) @@ -495,31 +402,7 @@ private static string RedactSupportValue(string? value) if (string.IsNullOrWhiteSpace(value)) return "unknown"; - try - { - var redacted = ValueUrlHostPattern.Replace( - value, - match => $"{match.Groups["scheme"].Value}://"); - - redacted = TokenSanitizer.IpV6Pattern.Replace(redacted, TokenSanitizer.RedactIfValidIpV6); - - redacted = ValueIpPattern.Replace(redacted, ""); - - redacted = ValueEmailPattern.Replace(redacted, ""); - - redacted = ValueUserAtHostPattern.Replace(redacted, "@"); - - redacted = ValueHostAfterToPattern.Replace(redacted, ""); - - redacted = ValueLeadingHostPattern.Replace(redacted, ""); - - return redacted; - } - catch (RegexMatchTimeoutException) - { - // Fail-closed: see TokenSanitizer.SanitizerTimeoutSentinel. - return TokenSanitizer.SanitizerTimeoutSentinel; - } + return TokenSanitizer.SanitizeLogMessage(value); } private static string BuildChannelDetail(ChannelCommandCenterInfo channel) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs index 5b97390ed..aea78d24e 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs @@ -1,18 +1,20 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; namespace OpenClawTray.Helpers; /// -/// Opens the native Win32 IFileOpenDialog on a dedicated STA thread. -/// UWP FileOpenPicker throws COMException in unpackaged / self-hosted WinUI 3 apps, -/// so we use the COM dialog directly. IFileOpenDialog is an STA COM object and must +/// Opens native Win32 file dialogs on a dedicated STA thread. +/// UWP FileOpenPicker/FileSavePicker are unreliable in unpackaged / self-hosted WinUI 3 apps, +/// so we use the COM dialogs directly. These STA COM objects must /// run on an STA thread — using a dedicated STA thread avoids hangs/failures from /// shell extensions when called from MTA thread-pool threads. /// +[SupportedOSPlatform("windows")] internal static class Win32FilePickerHelper { /// @@ -99,11 +101,73 @@ private static Task> PickFilesAsync(IntPtr ownerHwnd, stri return tcs.Task; } + /// + /// Shows a "Save as" dialog owned by . + /// Returns the selected file path, or null if cancelled. + /// + public static Task PickSaveFileAsync( + IntPtr ownerHwnd, + string title = "Save as", + string suggestedFileName = "", + string defaultExtension = "txt") + { + var tcs = new TaskCompletionSource(); + var staThread = new Thread(() => + { + IntPtr filterSpecPtr = IntPtr.Zero; + try + { + var dialog = (IFileSaveDialog)new FileSaveDialogClass(); + dialog.SetOptions(FOS.FOS_FORCEFILESYSTEM | FOS.FOS_PATHMUSTEXIST | FOS.FOS_OVERWRITEPROMPT); + dialog.SetTitle(title); + dialog.SetDefaultExtension(defaultExtension); + if (!string.IsNullOrWhiteSpace(suggestedFileName)) + dialog.SetFileName(suggestedFileName); + + var filterSpec = new COMDLG_FILTERSPEC("Text file (*.txt)", "*.txt"); + filterSpecPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf()); + Marshal.StructureToPtr(filterSpec, filterSpecPtr, fDeleteOld: false); + dialog.SetFileTypes(1, filterSpecPtr); + dialog.SetFileTypeIndex(1); + + var hr = dialog.Show(ownerHwnd); + if (hr < 0) + { + tcs.SetResult(null); // cancelled or error + return; + } + + dialog.GetResult(out var item); + item.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var filePath); + tcs.SetResult(filePath); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + finally + { + if (filterSpecPtr != IntPtr.Zero) + { + Marshal.DestroyStructure(filterSpecPtr); + Marshal.FreeCoTaskMem(filterSpecPtr); + } + } + }); + staThread.SetApartmentState(ApartmentState.STA); + staThread.IsBackground = true; + staThread.Start(); + return tcs.Task; + } + // ── COM interop ────────────────────────────────────────────────── [ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")] private class FileOpenDialogClass { } + [ComImport, Guid("C0B4E2F3-BA21-4773-8DBA-335EC946EB8B")] + private class FileSaveDialogClass { } + [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IFileOpenDialog @@ -136,6 +200,56 @@ private interface IFileOpenDialog void GetSelectedItems(out IntPtr ppsai); } + [ComImport, Guid("84bccd23-5fde-4cdb-aea4-af64b83d78ab")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IFileSaveDialog + { + [PreserveSig] int Show(IntPtr parent); + void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec); + void SetFileTypeIndex(uint iFileType); + void GetFileTypeIndex(out uint piFileType); + void Advise(IntPtr pfde, out uint pdwCookie); + void Unadvise(uint dwCookie); + void SetOptions(FOS fos); + void GetOptions(out FOS pfos); + void SetDefaultFolder(IShellItem psi); + void SetFolder(IShellItem psi); + void GetFolder(out IShellItem ppsi); + void GetCurrentSelection(out IShellItem ppsi); + void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName); + void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle); + void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText); + void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel); + void GetResult(out IShellItem ppsi); + void AddPlace(IShellItem psi, int fdap); + void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension); + void Close(int hr); + void SetClientGuid(ref Guid guid); + void ClearClientData(); + void SetFilter(IntPtr pFilter); + void SetSaveAsItem(IShellItem psi); + void SetProperties(IntPtr pStore); + void SetCollectedProperties(IntPtr pList, bool fAppendDefault); + void GetProperties(out IntPtr ppStore); + void ApplyProperties(IShellItem psi, IntPtr pStore, IntPtr hwnd, IntPtr pSink); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private readonly struct COMDLG_FILTERSPEC + { + [MarshalAs(UnmanagedType.LPWStr)] + public readonly string pszName; + [MarshalAs(UnmanagedType.LPWStr)] + public readonly string pszSpec; + + public COMDLG_FILTERSPEC(string name, string spec) + { + pszName = name; + pszSpec = spec; + } + } + [ComImport, Guid("B63EA76D-1F85-456F-A19C-48159EFA858B")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IShellItemArray @@ -166,6 +280,8 @@ private enum FOS : uint FOS_FORCEFILESYSTEM = 0x40, FOS_ALLOWMULTISELECT = 0x200, FOS_FILEMUSTEXIST = 0x1000, + FOS_PATHMUSTEXIST = 0x800, + FOS_OVERWRITEPROMPT = 0x2, } private enum SIGDN : uint diff --git a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs index 93643f4ec..e032159f7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs @@ -143,7 +143,7 @@ private async Task OnCopySupportClickAsync() + $"Gateway: {CurrentApp.Settings?.GetEffectiveGatewayUrl() ?? "n/a"}\n"; } - ClipboardHelper.CopyText(context); + ClipboardHelper.CopyText(DiagnosticsExportSanitizer.SanitizeTextBlock(context)); await Task.CompletedTask; } diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml index e1e0be199..f435fba9e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml @@ -157,8 +157,8 @@ diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs index 409615f40..78bd9ff2e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs @@ -85,6 +85,7 @@ private enum DetailMode { None, Log } // Plain-text mirror of log rows for the Copy toolbar action. // Capped to MaxLogRows in O(1) via Queue. private readonly Queue _detailPlainLines = new(); + private bool _bundlePreviewOpen; public DebugPage() { @@ -284,7 +285,7 @@ private void OnDetailRefresh(object sender, RoutedEventArgs e) private void OnDetailCopy(object sender, RoutedEventArgs e) { - ClipboardHelper.CopyText(string.Concat(_detailPlainLines)); + ClipboardHelper.CopyText(DiagnosticsExportSanitizer.SanitizeTextBlock(string.Concat(_detailPlainLines))); } private void OnDetailOpenFile(object sender, RoutedEventArgs e) @@ -336,17 +337,9 @@ private async Task LoadLogFileAsync(int generation) string[] lines; try { - // Hanselman v1 review findings #2 and #4: - // #2 — Logger holds the log open with FileAccess.Write + - // FileShare.Read (Logger.cs:109). Default File.ReadLines - // opens with FileShare.Read which excludes Write — so - // every read attempt failed with IOException as long - // as Logger was active (essentially always). The - // explicit FileShare.ReadWrite below is required for - // concurrent read while Logger holds the writer. - // #4 — Read tail on a background thread so a 5 MB log - // rotation does not stall the UI. - lines = await Task.Run(() => ReadLogTail(LogPath, 200)); + lines = await Task.Run(() => DiagnosticsLogTailReader.ReadSanitizedTail( + LogPath, + new DiagnosticsTailOptions(MaxLines: 200, MaxLineChars: 8_000, MaxSectionChars: 128_000, MaxReadBytes: 512_000)).ToArray()); } catch (Exception ex) { @@ -373,23 +366,6 @@ private async Task LoadLogFileAsync(int generation) ScrollDetailToEnd(); } - private static string[] ReadLogTail(string path, int tailCount) - { - // FileShare.ReadWrite lets us coexist with the Logger writer. - // Rolling Queue keeps memory at O(tailCount) instead of - // O(file size). - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var sr = new StreamReader(fs); - var queue = new Queue(tailCount); - string? line; - while ((line = sr.ReadLine()) != null) - { - if (queue.Count == tailCount) queue.Dequeue(); - queue.Enqueue(line); - } - return queue.ToArray(); - } - private static Paragraph CreateLogParagraph(string line) { var para = new Paragraph { Margin = new Thickness(0, 0, 0, 1) }; @@ -454,14 +430,31 @@ private void ScrollDetailToEnd() private void OnCreateDiagnosticsBundle(object sender, RoutedEventArgs e) => AsyncEventHandlerGuard.Run( - () => ShowBundlePreviewAsync( - title: "Diagnostics bundle", - buildText: CommandCenterTextHelper.BuildDebugBundle, - suggestedFileName: $"openclaw-diagnostics-{DateTime.Now:yyyyMMdd-HHmmss}.txt", - headerCaption: "This is the complete bundle that would be copied or saved."), + ShowDiagnosticsBundlePreviewAsync, new OpenClawTray.AppLogger(), nameof(OnCreateDiagnosticsBundle)); + private async Task ShowDiagnosticsBundlePreviewAsync() + { + if (_bundlePreviewOpen) + return; + + _bundlePreviewOpen = true; + try + { + var connectionEvents = CurrentApp.GetConnectionDiagnosticEvents().ToArray(); + await ShowBundlePreviewAsync( + title: "Diagnostics bundle", + buildText: state => DiagnosticsBundleBuilder.BuildCached(state, connectionEvents), + suggestedFileName: $"openclaw-diagnostics-{DateTime.Now:yyyyMMdd-HHmmss}.txt", + headerCaption: "This is the complete sanitized bundle that would be copied or saved. Review before sharing."); + } + finally + { + _bundlePreviewOpen = false; + } + } + private async Task ShowBundlePreviewAsync( string title, Func buildText, @@ -472,23 +465,42 @@ private async Task ShowBundlePreviewAsync( var state = CurrentApp.BuildCommandCenterState(); if (state == null) return; - string text; - try + var buildTask = Task.Run(() => { - text = buildText(state) ?? string.Empty; - } - catch (Exception ex) - { - text = $"Failed to build diagnostics bundle: {ex.Message}"; - } + try + { + return buildText(state) ?? string.Empty; + } + catch (Exception ex) + { + Logger.Error($"Diagnostics bundle build failed: {ex}"); + return $"Failed to build diagnostics bundle: {ex.Message}"; + } + }); var dialog = new DiagnosticsBundleDialog { XamlRoot = XamlRoot, Title = title }; // Just-in-time HWND resolution so a Hub-window close that happens // between dialog open and Save click can't land a stale handle in // the file picker (Hanselman v2 #4). - dialog.Configure(text, headerCaption, suggestedFileName, + dialog.Configure("Preparing diagnostics bundle…", headerCaption, suggestedFileName, hwndProvider: () => CurrentApp.GetHubWindowHandle()); - await dialog.ShowAsync(); + dialog.SetBundleText("Preparing diagnostics bundle…", isReady: false); + var updateTask = UpdateBundleDialogWhenReadyAsync(dialog, buildTask); + await Task.Yield(); + try + { + await dialog.ShowAsync(); + } + finally + { + await updateTask; + } + } + + private async Task UpdateBundleDialogWhenReadyAsync(DiagnosticsBundleDialog dialog, Task buildTask) + { + var text = await buildTask; + dialog.SetBundleText(text, isReady: true); } private void OnOpenDiagnosticsFolder(object sender, RoutedEventArgs e) @@ -509,7 +521,9 @@ private void OnCopySupportContext(object sender, RoutedEventArgs e) => CopyDiagnosticText("Support context", CommandCenterTextHelper.BuildSupportContext); private void OnCopyDebugBundle(object sender, RoutedEventArgs e) - => CopyDiagnosticText("Debug bundle", CommandCenterTextHelper.BuildDebugBundle); + => CopyDiagnosticText( + "Summary debug bundle", + CommandCenterTextHelper.BuildDebugBundle); private void OnCopyBrowserSetup(object sender, RoutedEventArgs e) => CopyDiagnosticText("Browser setup guidance", CommandCenterTextHelper.BuildBrowserSetupGuidance); @@ -526,7 +540,7 @@ private void CopyDiagnosticText(string label, Func? connectionEvents = null, + DiagnosticsBundlePaths? paths = null, + DateTimeOffset? now = null) + { + ArgumentNullException.ThrowIfNull(state); + return Build(state, connectionEvents, paths); + } + + public static string Build( + GatewayCommandCenterState state, + IReadOnlyList? connectionEvents = null, + DiagnosticsBundlePaths? paths = null) + { + ArgumentNullException.ThrowIfNull(state); + paths ??= DiagnosticsBundlePaths.Default(); + + var builder = new StringBuilder(); + builder.AppendLine("OpenClaw Windows Tray Diagnostics Bundle"); + builder.AppendLine($"Generated: {DateTimeOffset.Now:O}"); + builder.AppendLine(); + builder.AppendLine("## Manifest"); + builder.AppendLine("Included:"); + builder.AppendLine("- Generated support/debug summaries"); + builder.AppendLine("- Connection event timeline"); + builder.AppendLine("- Tray log tail"); + builder.AppendLine("- Structured diagnostics JSONL tail"); + builder.AppendLine("- Crash log tail"); + builder.AppendLine("- Latest setup log tails"); + builder.AppendLine(); + builder.AppendLine("Privacy boundary:"); + builder.AppendLine("- Export is read-only: source logs are tail-read and are never rewritten by preview/copy/save."); + builder.AppendLine("- Log tails are bounded first, then sanitized into this bundle as a defense-in-depth export boundary."); + builder.AppendLine("- Tokens, bootstrap/shared credentials, bearer headers, API keys, passwords, setup codes, DPAPI blobs, private keys, URLs, emails, IPs, and user paths are redacted before emission."); + builder.AppendLine("- Raw settings.json, gateways.json, mcp-token.txt, device-key-ed25519.json, screenshots, recordings, chat payloads, camera data, and microphone data are not included."); + builder.AppendLine("- Files, lines, bytes per line, bytes per section, and total bundle size are capped and marked inline when truncated."); + builder.AppendLine(); + builder.AppendLine("Sources:"); + AppendSource(builder, "Tray log", paths.TrayLogPath); + AppendSource(builder, "Tray log archive", paths.TrayLogArchivePath); + AppendSource(builder, "Diagnostics JSONL", paths.DiagnosticsJsonlPath); + AppendSource(builder, "Crash log", paths.CrashLogPath); + AppendSource(builder, "Setup logs", paths.SetupLogDirectory); + builder.AppendLine(); + + AppendSanitizedSection(builder, "Generated Debug Summary", CommandCenterTextHelper.BuildDebugBundle(state)); + AppendPreSanitizedSection(builder, "Connection Event Timeline", BuildConnectionTimeline(connectionEvents)); + builder.Append(DiagnosticsLogTailReader.BuildSection("Tray Log Tail", paths.TrayLogPath, StandardTail)); + builder.Append(DiagnosticsLogTailReader.BuildSection("Tray Log Archive Tail", paths.TrayLogArchivePath, ShortTail)); + builder.Append(DiagnosticsLogTailReader.BuildSection("Structured Diagnostics JSONL Tail", paths.DiagnosticsJsonlPath, JsonlTail)); + builder.Append(DiagnosticsLogTailReader.BuildSection("Crash Log Tail", paths.CrashLogPath, ShortTail)); + AppendLatestSetupLogs(builder, paths.SetupLogDirectory); + + return TruncateBundle(builder.ToString()); + } + + private static void AppendSource(StringBuilder builder, string label, string? path) + { + builder.Append("- "); + builder.Append(label); + builder.Append(": "); + builder.AppendLine(string.IsNullOrWhiteSpace(path) + ? "not configured" + : FormatPath(path)); + } + + private static void AppendSanitizedSection(StringBuilder builder, string title, string content) + { + builder.AppendLine($"## {title}"); + builder.AppendLine(DiagnosticsExportSanitizer.SanitizeTextBlock(content).TrimEnd()); + builder.AppendLine(); + } + + private static void AppendPreSanitizedSection(StringBuilder builder, string title, string content) + { + builder.AppendLine($"## {title}"); + builder.AppendLine(content.TrimEnd()); + builder.AppendLine(); + } + + private static string BuildConnectionTimeline(IReadOnlyList? events) + { + if (events is not { Count: > 0 }) + return "No connection diagnostic events recorded."; + + var builder = new StringBuilder(); + foreach (var evt in events.TakeLast(200)) + { + builder.Append(evt.Timestamp.ToUniversalTime().ToString("O")); + builder.Append(" ["); + builder.Append(DiagnosticsExportSanitizer.SanitizeTextBlock(evt.Category)); + builder.Append("] "); + builder.Append(DiagnosticsExportSanitizer.SanitizeTextBlock(evt.Message)); + if (!string.IsNullOrWhiteSpace(evt.Detail)) + { + builder.Append(" — "); + builder.Append(DiagnosticsExportSanitizer.SanitizeTextBlock(evt.Detail)); + } + builder.AppendLine(); + } + return builder.ToString(); + } + + private static void AppendLatestSetupLogs(StringBuilder builder, string? setupLogDirectory) + { + builder.AppendLine("## Latest Setup Log Tails"); + builder.AppendLine($"Source: {FormatPath(setupLogDirectory)}"); + + if (string.IsNullOrWhiteSpace(setupLogDirectory) || !Directory.Exists(setupLogDirectory)) + { + builder.AppendLine("Status: not found"); + builder.AppendLine(); + return; + } + + IReadOnlyList latestLogs; + try + { + latestLogs = EnumerateLatestSetupLogFiles(setupLogDirectory, MaxSetupLogFiles + 1); + } + catch (Exception ex) when (IsSetupLogEnumerationException(ex)) + { + Logger.Warn($"Diagnostics bundle setup log enumeration failed: {ex.Message}"); + builder.AppendLine($"Status: setup logs unavailable ({ex.GetType().Name})"); + builder.AppendLine(); + return; + } + + if (latestLogs.Count == 0) + { + builder.AppendLine("Status: no setup logs found"); + builder.AppendLine(); + return; + } + + builder.AppendLine(); + foreach (var file in latestLogs.Take(MaxSetupLogFiles)) + { + builder.Append(DiagnosticsLogTailReader.BuildSection( + $"Setup Log Tail: {file.Name}", + file.FullName, + ShortJsonlTail)); + } + if (latestLogs.Count > MaxSetupLogFiles) + builder.AppendLine($"[truncated setup logs at {MaxSetupLogFiles} files]"); + } + + private static string FormatPath(string? path) => + string.IsNullOrWhiteSpace(path) + ? "not configured" + : FormatSourcePath(path); + + private static string FormatSourcePath(string path) + { + var fileName = Path.GetFileName(path); + return string.IsNullOrWhiteSpace(fileName) ? "configured" : fileName; + } + + internal static void ClearBundleCacheForTest() + { + // Preserved for existing tests; bundle generation is intentionally uncached. + } + + private static IReadOnlyList EnumerateLatestSetupLogFiles(string setupLogDirectory, int maxFiles) => + Directory.EnumerateFiles(setupLogDirectory, "*.jsonl", SearchOption.TopDirectoryOnly) + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .Take(maxFiles) + .ToList(); + + private static IEnumerable EnumerateLatestSetupLogPaths(string setupLogDirectory) + { + IReadOnlyList latestLogs; + try + { + latestLogs = EnumerateLatestSetupLogFiles(setupLogDirectory, MaxSetupLogFiles); + } + catch (Exception ex) when (IsSetupLogEnumerationException(ex)) + { + Logger.Warn($"Diagnostics bundle setup log cache signature failed: {ex.Message}"); + yield break; + } + + foreach (var file in latestLogs) + yield return file.FullName; + } + + private static bool IsSetupLogEnumerationException(Exception ex) => + ex is IOException or UnauthorizedAccessException or NotSupportedException or PathTooLongException; + + private static string TruncateBundle(string text) + { + if (text.Length <= MaxTotalBundleChars) + return text; + + return text[..MaxTotalBundleChars] + Environment.NewLine + $"[truncated bundle at {MaxTotalBundleChars} chars]"; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs index 29b657091..f9329ac33 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs @@ -12,7 +12,8 @@ internal sealed class DiagnosticsClipboardService { private readonly Func _captureState; - public DiagnosticsClipboardService(Func captureState) + public DiagnosticsClipboardService( + Func captureState) { _captureState = captureState; } @@ -21,7 +22,7 @@ public void CopyDiagnostic(string label, Func { try { - App.CopyTextToClipboard(format(_captureState())); + App.CopyTextToClipboard(DiagnosticsExportSanitizer.SanitizeTextBlock(format(_captureState()))); Logger.Info($"Copied {label} from deep link"); } catch (Exception ex) @@ -34,7 +35,7 @@ public void CopySupportContext() => CopyDiagnostic("support context", CommandCenterTextHelper.BuildSupportContext); public void CopyDebugBundle() => - CopyDiagnostic("debug bundle", CommandCenterTextHelper.BuildDebugBundle); + CopyDiagnostic("summary debug bundle", CommandCenterTextHelper.BuildDebugBundle); public void CopyBrowserSetupGuidance() => CopyDiagnostic("browser setup guidance", CommandCenterTextHelper.BuildBrowserSetupGuidance); diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsExportSanitizer.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsExportSanitizer.cs new file mode 100644 index 000000000..2a6750f92 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsExportSanitizer.cs @@ -0,0 +1,151 @@ +using OpenClaw.Shared; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace OpenClawTray.Services; + +internal enum DiagnosticsExportLineKind +{ + Text, + Jsonl +} + +internal static class DiagnosticsExportSanitizer +{ + internal const string UnsafeTextLineSentinel = "[REDACTED_UNSAFE_LOG_LINE]"; + internal const string UnsafeJsonlLineSentinel = """{"event":"redacted_unsafe_log_line"}"""; + + private static readonly JsonSerializerOptions ReadableJsonOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public static string SanitizeTextBlock(string? text, DiagnosticsExportLineKind kind = DiagnosticsExportLineKind.Text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + var normalized = kind == DiagnosticsExportLineKind.Jsonl + ? NormalizeLegacyJsonlText(text) + : text; + var sanitized = TokenSanitizer.SanitizeLogMessage(normalized); + return sanitized == TokenSanitizer.SanitizerTimeoutSentinel + ? kind == DiagnosticsExportLineKind.Jsonl ? UnsafeJsonlLineSentinel : UnsafeTextLineSentinel + : sanitized; + } + + public static string SanitizeLine(string? line, DiagnosticsExportLineKind kind = DiagnosticsExportLineKind.Text) + { + if (line is null) + return string.Empty; + + var sanitized = SanitizeTextBlock(line, kind); + if (kind != DiagnosticsExportLineKind.Jsonl) + return sanitized; + + return IsValidJsonLine(sanitized) ? sanitized : UnsafeJsonlLineSentinel; + } + + internal static string SanitizeLineForTest(string? line, DiagnosticsExportLineKind kind = DiagnosticsExportLineKind.Text) => + SanitizeLine(line, kind); + + private static string NormalizeLegacyJsonlText(string text) + { + var lines = text + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + + for (var i = 0; i < lines.Length; i++) + lines[i] = NormalizeLegacyJsonLine(lines[i]); + + return string.Join('\n', lines); + } + + private static string NormalizeLegacyJsonLine(string line) + { + if (!NeedsLegacyJsonNormalization(line)) + return line; + + try + { + var node = JsonNode.Parse(line); + var normalized = NormalizeJsonNode(node); + return normalized?.ToJsonString(ReadableJsonOptions) ?? line; + } + catch (JsonException) + { + return line; + } + } + + private static bool NeedsLegacyJsonNormalization(string text) => + text.Contains("\\u0022", StringComparison.OrdinalIgnoreCase) || + text.Contains("\\u002B", StringComparison.OrdinalIgnoreCase) || + text.Contains("\\u003C", StringComparison.OrdinalIgnoreCase) || + text.Contains("\\u003E", StringComparison.OrdinalIgnoreCase) || + text.Contains("\\u0026", StringComparison.OrdinalIgnoreCase) || + text.Contains("\\u0060", StringComparison.OrdinalIgnoreCase) || + text.Contains("\\r\\n", StringComparison.Ordinal); + + private static JsonNode? NormalizeJsonNode(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var normalizedObject = new JsonObject(); + foreach (var property in obj) + normalizedObject[property.Key] = NormalizeJsonNode(property.Value); + return normalizedObject; + + case JsonArray array: + var normalizedArray = new JsonArray(); + foreach (var item in array) + normalizedArray.Add(NormalizeJsonNode(item)); + return normalizedArray; + + case JsonValue value when value.TryGetValue(out var text): + var normalized = NormalizeLogString(text); + var trimmed = normalized.TrimStart(); + if (trimmed.StartsWith('{') || trimmed.StartsWith('[')) + { + try + { + return NormalizeJsonNode(JsonNode.Parse(normalized)); + } + catch (JsonException) + { + // Keep malformed JSON-shaped text as flattened text. + } + } + + return JsonValue.Create(normalized); + + default: + return node?.DeepClone(); + } + } + + private static string NormalizeLogString(string value) => + value + .Replace("\r\n", " ", StringComparison.Ordinal) + .Replace('\r', ' ') + .Replace('\n', ' '); + + private static bool IsValidJsonLine(string line) + { + if (string.IsNullOrWhiteSpace(line)) + return true; + + try + { + using var _ = JsonDocument.Parse(line); + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsJsonlService.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsJsonlService.cs index 5f5ba049a..73a80f9fa 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsJsonlService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsJsonlService.cs @@ -1,6 +1,8 @@ using System; using System.IO; +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Channels; using System.Threading.Tasks; using OpenClaw.Shared; @@ -23,6 +25,10 @@ public static class DiagnosticsJsonlService private static Channel? s_channel; private static Task? s_writerTask; private static readonly object s_initLock = new(); + private static readonly JsonSerializerOptions JsonOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; public static string? FilePath => s_filePath; @@ -66,14 +72,7 @@ public static void Write(string eventName, object metadata) try { - var record = new - { - ts = DateTimeOffset.Now, - @event = eventName, - metadata - }; - var line = TokenSanitizer.SanitizeLogMessage(JsonSerializer.Serialize(record)); - channel.Writer.TryWrite(line); + channel.Writer.TryWrite(FormatRecordLine(eventName, metadata)); } catch (NotSupportedException ex) { @@ -83,6 +82,105 @@ public static void Write(string eventName, object metadata) } } + private static object? SanitizeMetadata(object? metadata) + { + if (metadata is null) + return null; + + if (metadata is string text) + return TokenSanitizer.SanitizeLogMessage(text); + + var node = JsonSerializer.SerializeToNode(metadata, JsonOptions); + return SanitizeJsonNode(node); + } + + private static string FormatRecordLine(string eventName, object metadata) + { + var record = new + { + ts = DateTimeOffset.Now, + @event = TokenSanitizer.SanitizeLogMessage(eventName), + metadata = SanitizeMetadata(metadata) + }; + return TokenSanitizer.SanitizeLogMessage(JsonSerializer.Serialize(record, JsonOptions)); + } + + private static JsonNode? SanitizeJsonNode(JsonNode? node) + { + switch (node) + { + case null: + return null; + case JsonObject obj: + foreach (var propertyName in obj.Select(property => property.Key).ToArray()) + { + obj[propertyName] = IsSensitiveMetadataKey(propertyName) + ? JsonValue.Create("[REDACTED]") + : SanitizeJsonNode(obj[propertyName]); + } + return obj; + case JsonArray array: + for (var i = 0; i < array.Count; i++) + array[i] = SanitizeJsonNode(array[i]); + return array; + case JsonValue value when value.TryGetValue(out var text): + return SanitizeStringValue(text); + default: + return node; + } + } + + private static JsonNode? SanitizeStringValue(string text) + { + var trimmed = text.TrimStart(); + if ((trimmed.StartsWith('{') || trimmed.StartsWith('[')) && + TryParseJsonString(text, out var parsed)) + { + return SanitizeJsonNode(parsed); + } + + return JsonValue.Create(NormalizeSingleLine(TokenSanitizer.SanitizeLogMessage(text))); + } + + private static bool TryParseJsonString(string text, out JsonNode? node) + { + try + { + node = JsonNode.Parse(text); + return node is not null; + } + catch (JsonException) + { + node = null; + return false; + } + } + + private static string NormalizeSingleLine(string text) => + text.Replace("\r\n", " ", StringComparison.Ordinal) + .Replace('\r', ' ') + .Replace('\n', ' '); + + private static bool IsSensitiveMetadataKey(string key) + { + var normalized = key.Trim().Replace("_", "-", StringComparison.Ordinal).ToLowerInvariant(); + return normalized.Contains("authorization", StringComparison.Ordinal) || + normalized.Contains("api-key", StringComparison.Ordinal) || + normalized.Contains("apikey", StringComparison.Ordinal) || + normalized.Contains("bearer", StringComparison.Ordinal) || + normalized.Contains("cookie", StringComparison.Ordinal) || + normalized.Contains("password", StringComparison.Ordinal) || + normalized.Contains("secret", StringComparison.Ordinal) || + normalized.Contains("setup-code", StringComparison.Ordinal) || + normalized.Contains("setupcode", StringComparison.Ordinal) || + normalized.Contains("token", StringComparison.Ordinal); + } + +#if OPENCLAW_TRAY_TESTS + internal static object? SanitizeMetadataForTest(object? metadata) => SanitizeMetadata(metadata); + internal static string FormatRecordLineForTest(string eventName, object metadata) => FormatRecordLine(eventName, metadata); +#endif + private static async Task WriterLoopAsync() { var channel = s_channel; diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsLogTailReader.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsLogTailReader.cs new file mode 100644 index 000000000..ddcdf0315 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsLogTailReader.cs @@ -0,0 +1,153 @@ +using System.Text; + +namespace OpenClawTray.Services; + +internal sealed record DiagnosticsTailOptions( + int MaxLines = 200, + int MaxLineChars = 8_000, + int MaxSectionChars = 256_000, + int MaxReadBytes = 512_000, + DiagnosticsExportLineKind LineKind = DiagnosticsExportLineKind.Text, + int SanitizationContextLines = 20); + +internal static class DiagnosticsLogTailReader +{ + public static string BuildSection(string title, string? path, DiagnosticsTailOptions? options = null) + { + options ??= new DiagnosticsTailOptions(); + var builder = new StringBuilder(); + builder.AppendLine($"## {title}"); + builder.AppendLine($"Source: {FormatPath(path)}"); + + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + builder.AppendLine("Status: not found"); + builder.AppendLine(); + return builder.ToString(); + } + + try + { + var lines = ReadSanitizedTail(path, options); + builder.AppendLine($"Lines: last {lines.Count} of up to {options.MaxLines}"); + builder.AppendLine("Sanitization: applied during export; source log was not modified."); + builder.AppendLine(); + + var writtenChars = 0; + foreach (var rawLine in lines) + { + var line = TruncateLine(rawLine, options.MaxLineChars); + if (writtenChars + line.Length > options.MaxSectionChars) + { + builder.AppendLine($"[truncated section at {options.MaxSectionChars} chars]"); + break; + } + + builder.AppendLine(line); + writtenChars += line.Length + Environment.NewLine.Length; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + builder.AppendLine($"Status: failed to read ({ex.GetType().Name})"); + } + + builder.AppendLine(); + return builder.ToString(); + } + + public static IReadOnlyList ReadTail(string path, int maxLines) + => ReadTail(path, maxLines, maxReadBytes: 512_000); + + public static IReadOnlyList ReadTail(string path, int maxLines, int maxReadBytes) + { + if (maxLines <= 0) + return []; + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + if (stream.Length == 0) + return []; + + const int ChunkSize = 8192; + var chunks = new Stack(); + var buffer = new byte[ChunkSize]; + var position = stream.Length; + var newlineCount = 0; + var bytesCollected = 0; + maxReadBytes = Math.Max(1, maxReadBytes); + + while (position > 0 && newlineCount <= maxLines && bytesCollected < maxReadBytes) + { + var bytesToRead = (int)Math.Min(Math.Min(buffer.Length, position), maxReadBytes - bytesCollected); + position -= bytesToRead; + stream.Seek(position, SeekOrigin.Begin); + stream.ReadExactly(buffer.AsSpan(0, bytesToRead)); + bytesCollected += bytesToRead; + + var chunk = buffer.AsSpan(0, bytesToRead).ToArray(); + chunks.Push(chunk); + + for (var i = bytesToRead - 1; i >= 0; i--) + { + if (buffer[i] == (byte)'\n') + newlineCount++; + } + } + + using var tailBytes = new MemoryStream(); + foreach (var chunk in chunks) + tailBytes.Write(chunk); + + var text = Encoding.UTF8.GetString(tailBytes.ToArray()) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + var lines = text.Split('\n').ToList(); + if (lines.Count > 0 && lines[^1].Length == 0) + lines.RemoveAt(lines.Count - 1); + return lines.TakeLast(maxLines).ToArray(); + } + + public static IReadOnlyList ReadSanitizedTail(string path, DiagnosticsTailOptions options) + { + var contextLineCount = Math.Max(0, options.SanitizationContextLines); + var lines = ReadTail(path, options.MaxLines + contextLineCount, options.MaxReadBytes); + return SanitizeTailLines(lines, options.LineKind) + .TakeLast(options.MaxLines) + .ToArray(); + } + + private static IReadOnlyList SanitizeTailLines(IReadOnlyList lines, DiagnosticsExportLineKind kind) + { + if (lines.Count == 0) + return []; + + var sanitized = DiagnosticsExportSanitizer.SanitizeTextBlock(string.Join('\n', lines), kind) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + + for (var i = 0; i < sanitized.Length; i++) + sanitized[i] = DiagnosticsExportSanitizer.SanitizeLine(sanitized[i], kind); + + return sanitized; + } + + private static string FormatPath(string? path) => + string.IsNullOrWhiteSpace(path) + ? "not configured" + : FormatSourcePath(path); + + private static string TruncateLine(string line, int maxChars) + { + if (maxChars <= 0 || line.Length <= maxChars) + return line; + + return line[..maxChars] + $"... [truncated {line.Length - maxChars} chars]"; + } + + private static string FormatSourcePath(string path) + { + var fileName = Path.GetFileName(path); + return string.IsNullOrWhiteSpace(fileName) ? "configured" : fileName; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 99822c772..debe322f9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -3757,10 +3757,10 @@ On your gateway host (Mac/Linux), run: Connection state, gateway URL, runtime, tunnel. - Copy full debug bundle + Copy summary debug bundle - Everything the preview dialog produces, no preview. + Generated summaries only; excludes log tails. Use Create diagnostics bundle to review logs before sharing. Copy browser setup guidance @@ -4782,9 +4782,6 @@ On your gateway host (Mac/Linux), run: Review before sharing - - Sensitive tokens, command arguments, payloads, and recordings are excluded by the bundle builder. - Disconnected @@ -4797,6 +4794,12 @@ On your gateway host (Mac/Linux), run: Advanced + + Log tails are sanitized and truncated. Sensitive tokens, payloads, recordings, and raw settings files are excluded. Review before sharing. + + + Review before sharing + Live diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 4305000fe..0c4de3320 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -3709,10 +3709,10 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Connection state, gateway URL, runtime, tunnel. - Copy full debug bundle + Copier le résumé de diagnostic - Everything the preview dialog produces, no preview. + Résumés générés uniquement ; exclut les extraits de journaux. Utilisez Créer un lot de diagnostics pour examiner les journaux avant de les partager. Copy browser setup guidance @@ -4734,9 +4734,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Examiner avant de partager - - Les jetons sensibles, les arguments de commande, les charges utiles et les enregistrements sont exclus par le générateur de lot. - Déconnecté @@ -4749,6 +4746,12 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Avancé + + Les extraits de journaux sont assainis et tronqués. Les jetons sensibles, les charges utiles, les enregistrements et les fichiers de paramètres bruts sont exclus. Vérifiez avant de partager. + + + Examiner avant de partager + En direct diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 22bd7c4bd..ee37f1adf 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -3710,10 +3710,10 @@ Voer op uw gateway-host (Mac/Linux) uit: Connection state, gateway URL, runtime, tunnel. - Copy full debug bundle + Samenvattende debugbundel kopiëren - Everything the preview dialog produces, no preview. + Alleen gegenereerde samenvattingen; logfragmenten zijn uitgesloten. Gebruik Diagnostische bundel maken om logs vóór het delen te bekijken. Copy browser setup guidance @@ -4735,9 +4735,6 @@ Voer op uw gateway-host (Mac/Linux) uit: Controleer voor het delen - - Gevoelige tokens, opdrachtargumenten, payloads en opnames worden door de bundelmaker uitgesloten. - Niet verbonden @@ -4750,6 +4747,12 @@ Voer op uw gateway-host (Mac/Linux) uit: Geavanceerd + + Logfragmenten worden opgeschoond en ingekort. Gevoelige tokens, payloads, opnames en onbewerkte instellingenbestanden worden uitgesloten. Controleer dit voordat u deelt. + + + Controleer voor het delen + Actueel diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 7940ba4bb..b382f2cba 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -3709,10 +3709,10 @@ Connection state, gateway URL, runtime, tunnel. - Copy full debug bundle + 复制摘要调试包 - Everything the preview dialog produces, no preview. + 仅包含生成的摘要;不包含日志尾部。请使用“创建诊断包”在共享前检查日志。 Copy browser setup guidance @@ -4734,9 +4734,6 @@ 分享前请审阅 - - 捆绑包生成器会排除敏感令牌、命令参数、负载和录制内容。 - 已断开连接 @@ -4749,6 +4746,12 @@ 高级 + + 日志尾部会经过清理并截断。敏感令牌、负载、录制内容和原始设置文件会被排除。共享前请先检查。 + + + 分享前请检查 + 实时 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 0c94a5d89..dbab6de9c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -3709,10 +3709,10 @@ Connection state, gateway URL, runtime, tunnel. - Copy full debug bundle + 複製摘要偵錯套件 - Everything the preview dialog produces, no preview. + 僅包含產生的摘要;不包含記錄尾端。請使用「建立診斷套件」在分享前檢查記錄。 Copy browser setup guidance @@ -4734,9 +4734,6 @@ 分享前請檢閱 - - 套件產生器會排除敏感權杖、命令引數、內容與錄製。 - 已中斷連線 @@ -4749,6 +4746,12 @@ 進階 + + 記錄尾端會經過清理並截斷。敏感權杖、內容、錄製資料與原始設定檔會被排除。分享前請先檢查。 + + + 分享前請檢查 + 即時 diff --git a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs index c7fca064d..19f21cd48 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs @@ -545,7 +545,7 @@ private static string FormatPlain(ConnectionDiagnosticEvent evt) private void OnCopyTimeline(object sender, RoutedEventArgs e) { - ClipboardHelper.CopyText(_plainBuffer.ToString()); + ClipboardHelper.CopyText(DiagnosticsExportSanitizer.SanitizeTextBlock(_plainBuffer.ToString())); } private void OnClearTimeline(object sender, RoutedEventArgs e) diff --git a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml index 53ef90679..d13b85f81 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml @@ -10,20 +10,47 @@ CloseButtonText="Close" DefaultButton="Primary"> - + - + HorizontalAlignment="Stretch" + Padding="14" + Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="4"> + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs index 2f41827ff..c9f56fef6 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs @@ -1,12 +1,11 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using OpenClaw.Shared; using OpenClawTray.Helpers; +using OpenClawTray.Services; using System; using System.IO; using System.Threading.Tasks; -using Windows.Storage; -using Windows.Storage.Pickers; -using WinRT.Interop; namespace OpenClawTray.Windows; @@ -34,8 +33,8 @@ public DiagnosticsBundleDialog() /// when "Save to file" is clicked, so we resolve the host HWND /// just-in-time (Hanselman v2 #4 + #7). If the host window has /// closed between Configure and Save, the provider returns - /// IntPtr.Zero and the picker silently no-ops instead of crashing - /// on a stale handle. + /// IntPtr.Zero and the picker reports a failure instead of crashing + /// on a stale handle or writing diagnostics somewhere unexpected. /// public void Configure(string bundleText, string headerCaption, string suggestedFileName, Func hwndProvider) { @@ -44,8 +43,16 @@ public void Configure(string bundleText, string headerCaption, string suggestedF ? "openclaw-diagnostics.txt" : suggestedFileName; _hwndProvider = hwndProvider; - BundleText.Text = _bundleText; BundleHeaderText.Text = headerCaption ?? string.Empty; + SetBundleText(_bundleText, isReady: true); + } + + public void SetBundleText(string bundleText, bool isReady) + { + _bundleText = bundleText ?? string.Empty; + BundleText.Text = _bundleText; + IsPrimaryButtonEnabled = isReady; + IsSecondaryButtonEnabled = isReady; } private void OnCopyClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) @@ -69,53 +76,69 @@ private void OnSaveClick(ContentDialog sender, ContentDialogButtonClickEventArgs { // Keep the dialog open after Save so the user can also Copy // (or save again to a different location). Mirrors OnCopyClick. - // Previously this method used GetDeferral + ContinueWith but - // because we unconditionally cancel the close, the deferral was - // dead code (Hanselman review finding #6). + // Use a deferral so picker/write failures can update the button + // instead of vanishing in a fire-and-forget task. args.Cancel = true; - _ = SaveToFileAsync(); + var deferral = args.GetDeferral(); + AsyncEventHandlerGuard.Run( + async () => + { + try + { + SecondaryButtonText = "Saving..."; + var result = await SaveToFileAsync(); + SecondaryButtonText = result.ButtonText; + } + finally + { + deferral.Complete(); + } + + var timer = DispatcherQueue.CreateTimer(); + timer.Interval = TimeSpan.FromSeconds(2); + timer.Tick += (_, _) => + { + SecondaryButtonText = "Save to file"; + timer.Stop(); + }; + timer.Start(); + }, + new OpenClawTray.AppLogger(), + nameof(OnSaveClick)); } - private async Task SaveToFileAsync() + private async Task SaveToFileAsync() { try { // Resolve HWND just-in-time so a closed/recreated host - // window never lands a stale handle in - // InitializeWithWindow.Initialize (Hanselman v2 #4). + // window never lands a stale handle in the native save dialog. var hwnd = _hwndProvider?.Invoke() ?? IntPtr.Zero; if (hwnd == IntPtr.Zero) { - System.Diagnostics.Debug.WriteLine("DiagnosticsBundleDialog save: no host hwnd; skipping picker."); - return; + Logger.Warn("DiagnosticsBundleDialog save skipped: no host hwnd available for save picker."); + return new SaveResult(null, "Save failed"); } - var picker = new FileSavePicker - { - SuggestedStartLocation = PickerLocationId.Desktop, - SuggestedFileName = Path.GetFileNameWithoutExtension(_suggestedFileName), - }; - picker.FileTypeChoices.Add("Text file", new[] { ".txt" }); - InitializeWithWindow.Initialize(picker, hwnd); - - var file = await picker.PickSaveFileAsync(); - if (file != null) + var selectedPath = await Win32FilePickerHelper.PickSaveFileAsync( + hwnd, + title: "Save diagnostics bundle", + suggestedFileName: Path.GetFileName(_suggestedFileName), + defaultExtension: "txt"); + if (!string.IsNullOrWhiteSpace(selectedPath)) { - await FileIO.WriteTextAsync(file, _bundleText); - SecondaryButtonText = "Saved"; - var timer = DispatcherQueue.CreateTimer(); - timer.Interval = TimeSpan.FromSeconds(2); - timer.Tick += (_, _) => - { - SecondaryButtonText = "Save to file"; - timer.Stop(); - }; - timer.Start(); + await File.WriteAllTextAsync(selectedPath, _bundleText); + return new SaveResult(selectedPath, "Saved"); } + + return new SaveResult(null, "Save cancelled"); } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"DiagnosticsBundleDialog save failed: {ex.Message}"); + Logger.Error($"DiagnosticsBundleDialog save failed: {ex}"); + return new SaveResult(null, "Save failed"); } } + + private sealed record SaveResult(string? Path, string ButtonText); } diff --git a/tests/OpenClaw.Connection.Tests/ConnectionDiagnosticsTests.cs b/tests/OpenClaw.Connection.Tests/ConnectionDiagnosticsTests.cs index 48006fc57..16008cff2 100644 --- a/tests/OpenClaw.Connection.Tests/ConnectionDiagnosticsTests.cs +++ b/tests/OpenClaw.Connection.Tests/ConnectionDiagnosticsTests.cs @@ -110,7 +110,24 @@ public void RecordWebSocketEvent_RecordsEvent() var all = _diag.GetAll(); Assert.Equal("websocket", all[0].Category); Assert.Equal("Connected", all[0].Message); - Assert.Equal("wss://test", all[0].Detail); + Assert.Equal("wss:///", all[0].Detail); + } + + [Fact] + public void Record_SanitizesSensitiveValuesBeforeBuffering() + { + _diag.Record( + "websocket", + "Connecting with Authorization: Bearer secret-token", + "wss://alice:password@gateway.example.com/reset?token=secret"); + + var evt = Assert.Single(_diag.GetAll()); + Assert.DoesNotContain("secret-token", evt.Message); + Assert.DoesNotContain("alice", evt.Detail); + Assert.DoesNotContain("password", evt.Detail); + Assert.DoesNotContain("token=secret", evt.Detail); + Assert.Contains("Authorization: Bearer [REDACTED]", evt.Message); + Assert.Contains("wss:///reset", evt.Detail); } [Fact] diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupLoggerTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupLoggerTests.cs index 60b22b997..4f0f04b6b 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupLoggerTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupLoggerTests.cs @@ -69,6 +69,42 @@ public void Redaction_RedactsGenericMessagesAndStructuredData() Assert.Contains("safe-request-id", serialized); } + [Fact] + public void Redaction_RedactsPiiFromMessagesAndStructuredData() + { + var entries = new List(); + using var logger = new SetupLogger(filePath: null); + logger.LogEmitted += (_, e) => entries.Add(e); + + logger.Info( + @"Failed for C:\Users\alice\AppData\Roaming\OpenClawTray\settings.json from alice@example.com at 10.1.2.3 via alice@host:22", + new + { + windowsPath = @"C:\Users\alice\AppData\Local\OpenClawTray\openclaw-tray.log", + forwardSlashPath = "C:/Users/alice/AppData/Local/OpenClawTray/openclaw-tray.log", + wslPath = "/mnt/c/Users/alice/AppData/Roaming/OpenClawTray/settings.json", + url = "https://gateway.example.com:19001/path/to/resource?token=secret", + email = "alice@example.com", + ip = "10.1.2.3", + ssh = "alice@host:22" + }); + + var serialized = System.Text.Json.JsonSerializer.Serialize( + entries[0], + new System.Text.Json.JsonSerializerOptions + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + Assert.DoesNotContain("alice", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("gateway.example.com", serialized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("10.1.2.3", serialized, StringComparison.OrdinalIgnoreCase); + Assert.Contains("%USERPROFILE%", serialized); + Assert.Contains("", serialized); + Assert.Contains("", serialized); + Assert.Contains("@", serialized); + } + [Fact] public void StepCompleted_RedactsResultMessage() { @@ -98,7 +134,7 @@ public void Redaction_RedactsHexTokens(int length) Assert.Single(entries); var serialized = System.Text.Json.JsonSerializer.Serialize(entries[0].Data); Assert.DoesNotContain(hexToken, serialized); - Assert.Contains("[REDACTED-HEX]", serialized); + Assert.Contains("[REDACTED", serialized); } [Fact] @@ -134,6 +170,68 @@ public void WritesToFile_WhenPathProvided() Assert.Contains("hello", lines[0]); } + [Fact] + public void CommandCompleted_WritesReadableJsonWithoutUnicodeOrNewlineEscapes() + { + var path = Path.Combine(_tempDir, "readable.jsonl"); + using (var logger = new SetupLogger(path)) + { + logger.CommandCompleted( + "wsl.exe", + new CommandResult( + 0, + """ + { + "cli": { + "version": "2026.5.28", + "entrypoint": "/home/openclaw/.openclaw/tools/node-v22.22.0/lib/node_modules/openclaw/dist/entry.js" + }, + "timestamp": "2026-06-03T18:42:49.9079864+00:00" + } + """, + "", + TimeSpan.FromSeconds(1), + false), + TimeSpan.FromSeconds(1)); + } + + var line = File.ReadAllText(path); + + Assert.DoesNotContain("\\u0022", line, StringComparison.Ordinal); + Assert.DoesNotContain("\\u002B", line, StringComparison.Ordinal); + Assert.DoesNotContain("\\r\\n", line, StringComparison.Ordinal); + Assert.Contains("+00:00", line, StringComparison.Ordinal); + Assert.Contains("\"stdout\":{\"cli\"", line, StringComparison.Ordinal); + } + + [Fact] + public void CommandCompleted_WritesPiiRedactedJsonl() + { + var path = Path.Combine(_tempDir, "pii.jsonl"); + using (var logger = new SetupLogger(path)) + { + logger.CommandCompleted( + "wsl.exe", + new CommandResult( + 0, + @"Using C:\Users\alice\AppData\Roaming\OpenClawTray and alice@example.com from 10.1.2.3", + "/mnt/c/Users/alice/.openclaw failed against https://gateway.example.com:19001/full/path?token=secret", + TimeSpan.FromSeconds(1), + false), + TimeSpan.FromSeconds(1)); + } + + var line = File.ReadAllText(path); + + Assert.DoesNotContain("alice", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("gateway.example.com", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("10.1.2.3", line, StringComparison.OrdinalIgnoreCase); + Assert.Contains("%USERPROFILE%", line); + Assert.Contains("", line); + Assert.Contains("", line); + Assert.Contains("https://:19001/full/…", line); + } + [Fact] public void RunId_IsPopulated() { diff --git a/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs b/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs index c5bedae43..d3a4a6a3a 100644 --- a/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs +++ b/tests/OpenClaw.Shared.Tests/TokenSanitizerTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared; +using System.Text.Json; namespace OpenClaw.Shared.Tests; @@ -237,6 +238,59 @@ public void SanitizeLogMessage_RedactsLocalUserPaths() Assert.Contains("%USERPROFILE%", sanitized); } + [Theory] + [InlineData(@"C:/Users/alice/AppData/Local/OpenClawTray/openclaw-tray.log")] + [InlineData(@"/mnt/c/Users/alice/AppData/Roaming/OpenClawTray/settings.json")] + [InlineData(@"""C:\\Users\\alice\\AppData\\Local\\OpenClawTray\\openclaw-tray.log""")] + [InlineData(@"""C:\\\\Users\\\\alice\\\\AppData\\\\Local\\\\OpenClawTray\\\\openclaw-tray.log""")] + public void SanitizeLogMessage_RedactsUserFolderPathVariants(string path) + { + var sanitized = TokenSanitizer.SanitizeLogMessage($"path={path}"); + + Assert.DoesNotContain("alice", sanitized, StringComparison.OrdinalIgnoreCase); + Assert.Contains("%USERPROFILE%", sanitized); + } + + [Theory] + [InlineData("/home/openclaw/.openclaw/sessions/abc123/session.jsonl")] + [InlineData("/home/alice/.config/openclaw/gateway-health.json")] + public void SanitizeLogMessage_RedactsLinuxHomePathVariants(string path) + { + var sanitized = TokenSanitizer.SanitizeLogMessage($"gateway payload path={path}"); + + Assert.DoesNotContain("/home/openclaw", sanitized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("/home/alice", sanitized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("openclaw/.openclaw", sanitized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("alice/.config", sanitized, StringComparison.OrdinalIgnoreCase); + Assert.Contains("$HOME", sanitized); + } + + [Fact] + public void SanitizeLogMessage_RedactsCurrentUserAndMachineNames() + { + var user = Environment.UserName; + var machine = Environment.MachineName; + if (!ShouldRedactLocalIdentityName(user) || !ShouldRedactLocalIdentityName(machine)) + { + return; + } + + var sanitized = TokenSanitizer.SanitizeLogMessage($"user={user} machine={machine}"); + + Assert.DoesNotContain(user, sanitized, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(machine, sanitized, StringComparison.OrdinalIgnoreCase); + Assert.Contains("", sanitized); + Assert.Contains("", sanitized); + } + + private static bool ShouldRedactLocalIdentityName(string? value) => + !string.IsNullOrWhiteSpace(value) && + value.Length >= 3 && + !value.Equals("user", StringComparison.OrdinalIgnoreCase) && + !value.Equals("admin", StringComparison.OrdinalIgnoreCase) && + !value.Equals("desktop", StringComparison.OrdinalIgnoreCase) && + !value.Equals("localhost", StringComparison.OrdinalIgnoreCase); + [Fact] public void SanitizeLogMessage_RedactsNetworkAndIdentityValues() { @@ -252,6 +306,71 @@ public void SanitizeLogMessage_RedactsNetworkAndIdentityValues() Assert.Contains("@:22", sanitized); } + [Fact] + public void SanitizeLogMessage_RedactsDiagnosticSecretShapesBeforePersisting() + { + var dpapi = "dpapi:abcdefghijklmnopqrstuvwxyz0123456789+/="; + var guid = "c5cacc40-2732-4008-a4d9-56b6a2c0643a"; + var input = string.Join(Environment.NewLine, + "-----BEGIN PRIVATE KEY-----", + "abcdefghijklmnopqrstuvwxyz", + "-----END PRIVATE KEY-----", + """{"setupCode":123456,"nonce":"nonce-secret","deviceId":"device-secret","raw_error_response":"raw-secret","ok":42}""", + "Cookie: sessionid=browser-cookie; csrftoken=csrf-secret", + $"protected={dpapi}", + "session=agent:abc123:some-session-key", + $"signed: v3|token|cli|operator|1779894994338|sig|{guid}|windows|desktop", + "openclaw connect --token shared-secret --setup-code setup-secret --password pass-secret"); + + var sanitized = TokenSanitizer.SanitizeLogMessage(input); + + foreach (var secret in new[] + { + "123456", + "nonce-secret", + "device-secret", + "raw-secret", + "browser-cookie", + "csrf-secret", + dpapi, + "agent:abc123:some-session-key", + "1779894994338", + guid, + "shared-secret", + "setup-secret", + "pass-secret", + "BEGIN PRIVATE KEY", + "abcdefghijklmnopqrstuvwxyz" + }) + { + Assert.DoesNotContain(secret, sanitized, StringComparison.OrdinalIgnoreCase); + } + + Assert.Contains("\"ok\":42", sanitized); + Assert.Contains("Cookie: [REDACTED]", sanitized); + Assert.Contains("signed: [REDACTED_HANDSHAKE]", sanitized); + Assert.Contains("[REDACTED_PRIVATE_KEY]", sanitized); + Assert.Contains("--token [REDACTED]", sanitized); + } + + [Fact] + public void SanitizeLogMessage_RedactsPrefixedTokensInsideJsonStringsWithoutBreakingJson() + { + const string input = """ + {"session":"agent:abc123:some-session-key","protected":"dpapi:abcdefghijklmnopqrstuvwxyz0123456789+/=","ok":42} + """; + + var sanitized = TokenSanitizer.SanitizeLogMessage(input); + + using var document = JsonDocument.Parse(sanitized); + var root = document.RootElement; + Assert.Equal("[REDACTED_SESSION_KEY]", root.GetProperty("session").GetString()); + Assert.Equal("dpapi:[REDACTED]", root.GetProperty("protected").GetString()); + Assert.Equal(42, root.GetProperty("ok").GetInt32()); + Assert.DoesNotContain("agent:abc123:some-session-key", sanitized); + Assert.DoesNotContain("abcdefghijklmnopqrstuvwxyz0123456789+/=", sanitized); + } + [Fact] public void SanitizeLogMessage_RedactsUrlUserInfoCredentials() { @@ -301,7 +420,7 @@ public void SanitizeLogMessage_DoesNotRedactTimestamps() public void SanitizeLogMessage_DoesNotRedactBracketedTimestampsOrShortHexTokens() { // Bracketed values that lack IPv6 structure must not be redacted: [HH:MM:SS], [hexword], [hex:hex]. - const string input = "Event [14:58:46] tag=[face] id=[abc] correlation=[dead:beef]"; + const string input = "Event [14:58:46] tag=[face] label=[abc] correlation=[dead:beef]"; var sanitized = TokenSanitizer.SanitizeLogMessage(input); Assert.Contains("[14:58:46]", sanitized); diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsBundleBuilderTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsBundleBuilderTests.cs new file mode 100644 index 000000000..248f29be5 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/DiagnosticsBundleBuilderTests.cs @@ -0,0 +1,420 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClawTray.Services; +using System.Text.RegularExpressions; +using System.Text.Json; + +namespace OpenClaw.Tray.Tests; + +public sealed class DiagnosticsBundleBuilderTests : IDisposable +{ + private readonly string _tempDir; + + public DiagnosticsBundleBuilderTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"diag-bundle-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + [Fact] + public void Build_IncludesSanitizedLogTailsAndConnectionTimeline() + { + var trayLog = Path.Combine(_tempDir, "openclaw-tray.log"); + var jsonl = Path.Combine(_tempDir, "diagnostics.jsonl"); + var crash = Path.Combine(_tempDir, "crash.log"); + var setupDir = Path.Combine(_tempDir, "Setup"); + Directory.CreateDirectory(setupDir); + var setupLog = Path.Combine(setupDir, "setup-engine-20260527.jsonl"); + + File.WriteAllText(trayLog, "Authentication failed token=tray-secret\nport 18789 refused\n"); + File.WriteAllText(jsonl, """{"event":"auth","metadata":{"token":"jsonl-secret","status":"failed"}}""" + "\n"); + File.WriteAllText(crash, "CRASH Authorization: Bearer crash-secret\n"); + File.WriteAllText(setupLog, """{"event":"setup","msg":"setupCode=setup-secret gateway did not become healthy"}""" + "\n"); + + var state = new GatewayCommandCenterState + { + ConnectionStatus = ConnectionStatus.Error, + Topology = new GatewayTopologyInfo + { + GatewayUrl = "wss://gateway.example.com:18789/path?token=secret", + DisplayName = "Remote", + Transport = "websocket", + Detail = "failed to connect to gateway.example.com:18789" + }, + PortDiagnostics = + [ + new PortDiagnosticInfo + { + Purpose = "Gateway endpoint", + Port = 18789, + IsListening = false, + Detail = "Local TCP port 18789 does not currently have a listener." + } + ] + }; + var events = new[] + { + new ConnectionDiagnosticEvent( + DateTime.UtcNow, + "error", + "Authentication failed", + TokenSanitizer.SanitizeLogMessage("Authorization: Bearer event-secret")) + }; + var paths = new DiagnosticsBundlePaths( + trayLog, + null, + jsonl, + crash, + setupDir); + + var bundle = DiagnosticsBundleBuilder.Build(state, events, paths); + + Assert.Contains("## Manifest", bundle); + Assert.Contains("## Connection Event Timeline", bundle); + Assert.Contains("## Tray Log Tail", bundle); + Assert.Contains("## Structured Diagnostics JSONL Tail", bundle); + Assert.Contains("## Crash Log Tail", bundle); + Assert.Contains("## Latest Setup Log Tails", bundle); + Assert.Contains("Authentication failed", bundle); + Assert.Contains("port 18789 refused", bundle); + Assert.Contains("gateway did not become healthy", bundle); + Assert.DoesNotContain("tray-secret", bundle); + Assert.DoesNotContain("jsonl-secret", bundle); + Assert.DoesNotContain("crash-secret", bundle); + Assert.DoesNotContain("setup-secret", bundle); + Assert.DoesNotContain("event-secret", bundle); + Assert.DoesNotContain("gateway.example.com", bundle); + } + + [Fact] + public void Build_PreservesConnectionTimelineIsoTimestamps() + { + var timestamp = new DateTime(2026, 6, 26, 16, 2, 42, DateTimeKind.Utc); + var events = new[] + { + new ConnectionDiagnosticEvent( + timestamp, + "transport", + "Connected to gateway", + "wss://gateway.example.com:18789/path") + }; + + var bundle = DiagnosticsBundleBuilder.Build(new GatewayCommandCenterState(), events); + + Assert.Contains("2026-06-26T16:02:42.0000000Z", bundle); + Assert.DoesNotContain(":02:42", bundle); + Assert.DoesNotContain("gateway.example.com", bundle); + } + + [Fact] + public void Build_AnnotatesMissingFilesInsteadOfFailing() + { + var state = new GatewayCommandCenterState(); + var paths = new DiagnosticsBundlePaths( + Path.Combine(_tempDir, "missing.log"), + null, + Path.Combine(_tempDir, "missing.jsonl"), + Path.Combine(_tempDir, "missing-crash.log"), + Path.Combine(_tempDir, "missing-setup")); + + var bundle = DiagnosticsBundleBuilder.Build(state, [], paths); + + Assert.Contains("Status: not found", bundle); + Assert.Contains("No connection diagnostic events recorded.", bundle); + Assert.Contains("Raw settings.json", bundle); + Assert.Contains("device-key-ed25519.json", bundle); + } + + [Fact] + public void BuildCached_RegeneratesWhenLogsChangeInsideReuseWindow() + { + DiagnosticsBundleBuilder.ClearBundleCacheForTest(); + var trayLog = Path.Combine(_tempDir, "openclaw-tray.log"); + File.WriteAllText(trayLog, "first\n"); + var paths = new DiagnosticsBundlePaths(trayLog, null, null, null, null); + var now = new DateTimeOffset(2026, 6, 23, 14, 0, 0, TimeSpan.Zero); + + var first = DiagnosticsBundleBuilder.BuildCached(new GatewayCommandCenterState(), [], paths, now); + File.AppendAllText(trayLog, "second\n"); + var second = DiagnosticsBundleBuilder.BuildCached(new GatewayCommandCenterState(), [], paths, now.AddSeconds(10)); + + Assert.NotEqual(first, second); + Assert.Contains("second", second); + } + + [Fact] + public void BuildCached_RegeneratesAfterReuseWindowWhenLogsChange() + { + DiagnosticsBundleBuilder.ClearBundleCacheForTest(); + var trayLog = Path.Combine(_tempDir, "openclaw-tray.log"); + File.WriteAllText(trayLog, "first\n"); + var paths = new DiagnosticsBundlePaths(trayLog, null, null, null, null); + var now = new DateTimeOffset(2026, 6, 23, 14, 0, 0, TimeSpan.Zero); + + var first = DiagnosticsBundleBuilder.BuildCached(new GatewayCommandCenterState(), [], paths, now); + File.AppendAllText(trayLog, "second\n"); + var second = DiagnosticsBundleBuilder.BuildCached(new GatewayCommandCenterState(), [], paths, now.AddSeconds(31)); + + Assert.NotEqual(first, second); + Assert.Contains("second", second); + } + + [Fact] + public void BuildCached_RegeneratesWhenDiagnosticStateContentChangesWithSameCounts() + { + DiagnosticsBundleBuilder.ClearBundleCacheForTest(); + var now = new DateTimeOffset(2026, 6, 26, 16, 0, 0, TimeSpan.Zero); + var firstState = new GatewayCommandCenterState + { + RecentActivity = + [ + new CommandCenterActivityInfo + { + Timestamp = now.UtcDateTime, + Category = "diagnostics", + Title = "first warning", + Details = "gateway health path /home/openclaw/.openclaw/sessions/old/session.jsonl" + } + ] + }; + var secondState = new GatewayCommandCenterState + { + RecentActivity = + [ + new CommandCenterActivityInfo + { + Timestamp = now.AddSeconds(1).UtcDateTime, + Category = "diagnostics", + Title = "second warning", + Details = "gateway health path /home/openclaw/.openclaw/sessions/new/session.jsonl" + } + ] + }; + + var first = DiagnosticsBundleBuilder.BuildCached(firstState, [], null, now); + var second = DiagnosticsBundleBuilder.BuildCached(secondState, [], null, now.AddSeconds(1)); + + Assert.NotEqual(first, second); + Assert.Contains("second warning", second); + Assert.DoesNotContain("first warning", second); + Assert.DoesNotContain("/home/openclaw", second, StringComparison.OrdinalIgnoreCase); + Assert.Contains("$HOME", second); + } + + [Fact] + public void DiagnosticsLogTailReader_ReadTail_ReturnsOnlyLastLines() + { + var log = Path.Combine(_tempDir, "large.log"); + File.WriteAllLines(log, Enumerable.Range(1, 300).Select(i => $"line-{i}")); + + var lines = DiagnosticsLogTailReader.ReadTail(log, 5); + + Assert.Equal(["line-296", "line-297", "line-298", "line-299", "line-300"], lines); + } + + [Fact] + public void DiagnosticsLogTailReader_ReadSanitizedTail_UsesContextBeforeVisibleTail() + { + var log = Path.Combine(_tempDir, "multiline-secret.log"); + File.WriteAllLines(log, + [ + "prefix", + "token:", + "visible-secret-value" + ]); + + var lines = DiagnosticsLogTailReader.ReadSanitizedTail( + log, + new DiagnosticsTailOptions(MaxLines: 1, SanitizationContextLines: 2)); + + Assert.Equal(["[REDACTED]"], lines); + } + + [Fact] + public void Build_RedactsLegacyMultilineSecretDuringExportWithoutRewritingSource() + { + var trayLog = Path.Combine(_tempDir, "openclaw-tray.log"); + var jsonl = Path.Combine(_tempDir, "diagnostics.jsonl"); + File.WriteAllText(trayLog, """ + {"event":"split-secret","metadata":{"token": + "split-token-secret"}} + """); + File.WriteAllText(jsonl, """ + {"event":"split-secret","metadata":{"token": + "split-token-secret"}} + """); + + var bundle = DiagnosticsBundleBuilder.Build( + new GatewayCommandCenterState(), + [], + new DiagnosticsBundlePaths( + trayLog, + null, + jsonl, + null, + null)); + + Assert.Contains("split-secret", bundle); + Assert.DoesNotContain("split-token-secret", bundle); + Assert.Contains("[REDACTED]", bundle); + Assert.Contains("split-token-secret", File.ReadAllText(trayLog)); + Assert.Contains("split-token-secret", File.ReadAllText(jsonl)); + } + + [Fact] + public void Build_RedactsLegacyPiiDuringExportWithoutRewritingSource() + { + var trayLog = Path.Combine(_tempDir, "openclaw-tray.log"); + var jsonl = Path.Combine(_tempDir, "diagnostics.jsonl"); + var crash = Path.Combine(_tempDir, "crash.log"); + var rawChat = Path.Combine(_tempDir, "chat-history-raw.log"); + File.WriteAllText(trayLog, @"Failed reading C:\Users\alice\AppData\Local\OpenClawTray\settings.json from alice@example.com" + "\n"); + File.WriteAllText(jsonl, """{"event":"test","metadata":{"path":"C:\\Users\\alice\\AppData\\Local\\OpenClawTray\\settings.json","ip":"10.1.2.3"}}""" + "\n"); + File.WriteAllText(crash, @"Unhandled at C:/Users/alice/source/repos/openclaw-windows-node/App.xaml.cs via alice@host:22" + "\n"); + File.WriteAllText(rawChat, """{"arguments":{"command":"grep C:\\Users\\alice\\source\\repos\\openclaw"}}""" + "\n"); + + var bundle = DiagnosticsBundleBuilder.Build( + new GatewayCommandCenterState(), + [], + new DiagnosticsBundlePaths( + trayLog, + null, + jsonl, + crash, + null)); + + Assert.DoesNotContain("alice", bundle, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("10.1.2.3", bundle, StringComparison.OrdinalIgnoreCase); + Assert.Contains("C:\\Users", File.ReadAllText(trayLog), StringComparison.OrdinalIgnoreCase); + Assert.Contains("C:\\\\Users", File.ReadAllText(jsonl), StringComparison.OrdinalIgnoreCase); + Assert.Contains("C:/Users", File.ReadAllText(crash), StringComparison.OrdinalIgnoreCase); + Assert.Contains("alice", File.ReadAllText(rawChat), StringComparison.OrdinalIgnoreCase); + Assert.Contains("%USERPROFILE%", bundle); + Assert.Contains("", bundle); + Assert.Contains("", bundle); + Assert.Contains("@:22", bundle); + } + + [Fact] + public void Build_NormalizesLegacyJsonlForExportWithoutRewritingSource() + { + var jsonl = Path.Combine(_tempDir, "diagnostics.jsonl"); + var persisted = """{"event":"structured","metadata":"{\u0022message\u0022:\u0022line1\r\nline2\u0022,\u0022apiKey\u0022:\u0022[REDACTED]\u0022}"}"""; + File.WriteAllText(jsonl, persisted); + + var bundle = DiagnosticsBundleBuilder.Build( + new GatewayCommandCenterState(), + [], + new DiagnosticsBundlePaths( + null, + null, + jsonl, + null, + null)); + + Assert.Contains("## Structured Diagnostics JSONL Tail", bundle); + Assert.DoesNotContain(@"\u0022", bundle); + Assert.DoesNotContain(@"\r\n", bundle); + Assert.Contains(@"\u0022", File.ReadAllText(jsonl)); + Assert.Contains(@"\r\n", File.ReadAllText(jsonl)); + Assert.Contains("[REDACTED]", bundle); + } + + [Fact] + public void Build_NormalizesLegacySetupJsonlForExportWithoutRewritingSource() + { + var setupDir = Path.Combine(_tempDir, "Setup"); + Directory.CreateDirectory(setupDir); + var setupLog = Path.Combine(setupDir, "setup-engine-20260603-184044.jsonl"); + var journalLog = Path.Combine(setupDir, "setup-engine-20260603-184044.journal.jsonl"); + File.WriteAllText(setupLog, + """ + {"ts":"2026-06-03T18:42:49.9079864\u002B00:00","run":"9f9f8a5056b7","level":"debug","msg":"cmd.done: wsl.exe exit=0 (1382ms)","data":{"exe":"wsl.exe","exit_code":0,"stdout":"{\r\n \u0022cli\u0022: {\r\n \u0022version\u0022: \u00222026.5.28\u0022\r\n },\r\n \u0022timestamp\u0022: \u00222026-06-03T18:42:49.9079864\u002B00:00\u0022\r\n}"}} + """); + File.WriteAllText(journalLog, + """ + {"Timestamp":"2026-06-03T18:40:44.7873386+00:00","StepId":"wsl-create","Event":"completed","Detail":"Created clean WSL2 distro at C:\\Users\\alice\\AppData\\Local\\OpenClawTray\\wsl\\OpenClawGateway"} + """); + + var bundle = DiagnosticsBundleBuilder.Build( + new GatewayCommandCenterState(), + [], + new DiagnosticsBundlePaths(null, null, null, null, setupDir)); + var persisted = File.ReadAllText(setupLog); + + Assert.Contains("## Setup Log Tail: setup-engine-20260603-184044.jsonl", bundle); + Assert.DoesNotContain("\\u0022", bundle, StringComparison.Ordinal); + Assert.DoesNotContain("\\u002B", bundle, StringComparison.Ordinal); + Assert.DoesNotContain("\\r\\n", bundle, StringComparison.Ordinal); + Assert.Contains("\\u0022", persisted, StringComparison.Ordinal); + Assert.Contains("\\u002B", persisted, StringComparison.Ordinal); + Assert.Contains("\\r\\n", persisted, StringComparison.Ordinal); + Assert.Contains("alice", File.ReadAllText(journalLog), StringComparison.OrdinalIgnoreCase); + Assert.Contains("+00:00", bundle, StringComparison.Ordinal); + Assert.Contains("\"stdout\":{\"cli\"", bundle, StringComparison.Ordinal); + } + + [Fact] + public void Build_FailsClosedForUnsafeJsonlLines() + { + var jsonl = Path.Combine(_tempDir, "diagnostics.jsonl"); + File.WriteAllText(jsonl, """{"event":"bad","token":""" + "\nunsafe-secret-value\n"); + + var bundle = DiagnosticsBundleBuilder.Build( + new GatewayCommandCenterState(), + [], + new DiagnosticsBundlePaths(null, null, jsonl, null, null)); + + Assert.Contains(DiagnosticsExportSanitizer.UnsafeJsonlLineSentinel, bundle); + Assert.DoesNotContain("unsafe-secret-value", bundle); + Assert.Contains("unsafe-secret-value", File.ReadAllText(jsonl)); + } + + [Fact] + public void Build_CapsSetupLogFiles() + { + var setupDir = Path.Combine(_tempDir, "Setup"); + Directory.CreateDirectory(setupDir); + for (var i = 0; i < 8; i++) + { + var path = Path.Combine(setupDir, $"setup-engine-{i}.jsonl"); + File.WriteAllText(path, $$"""{"event":"setup-{{i}}"}""" + "\n"); + File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(i)); + } + + var bundle = DiagnosticsBundleBuilder.Build( + new GatewayCommandCenterState(), + [], + new DiagnosticsBundlePaths(null, null, null, null, setupDir)); + + Assert.Equal(4, Regex.Matches(bundle, "## Setup Log Tail:").Count); + Assert.Contains("[truncated setup logs at 4 files]", bundle); + } + + [Fact] + public void DiagnosticsJsonlService_SanitizesAndStructuresNestedStringMetadataBeforeSerialization() + { + var metadata = new + { + rawJson = """{"apiKey":"jsonl-secret","message":"line1\r\nline2"}""", + nested = new { Authorization = "Bearer bearer-secret" }, + timestamp = "2026-06-22T15:49:22.112+00:00" + }; + + var json = DiagnosticsJsonlService.FormatRecordLineForTest("diagnostics.test", metadata); + + Assert.DoesNotContain("jsonl-secret", json); + Assert.DoesNotContain("bearer-secret", json); + Assert.DoesNotContain(@"\u0022", json); + Assert.DoesNotContain(@"\u002B00", json); + Assert.DoesNotContain(@"\r\n", json); + Assert.Contains("[REDACTED]", json); + Assert.Contains("line1 line2", json); + Assert.Contains("+00:00", json); + } +} diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs index 6053880c4..6c579ced2 100644 --- a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs @@ -206,7 +206,7 @@ public void DebugPage_CopySpecificCards_HaveCopyGlyph_NotChevron_AndFeedback() // Each copy handler must pass a human-readable label that // shows up in the feedback message. Assert.Contains("CopyDiagnosticText(\"Support context\"", cs); - Assert.Contains("CopyDiagnosticText(\"Debug bundle\"", cs); + Assert.Contains("CopyDiagnosticText(\r\n \"Summary debug bundle\"", cs); Assert.Contains("CopyDiagnosticText(\"Browser setup guidance\"", cs); Assert.Contains("CopyDiagnosticText(\"Port diagnostics\"", cs); Assert.Contains("CopyDiagnosticText(\"Capability diagnostics\"", cs); @@ -230,17 +230,244 @@ public void DebugPage_CopyFeedbackTimer_IsStoppedOnTeardown() } [Fact] - public void DebugPage_DetailView_UsesGenerationCounterForRaceSafety() + public void DebugPage_MainAndDetailViews_UseBoundedStretchLayout() { - var cs = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml"); - Assert.Contains("_detailGeneration", cs); - Assert.Contains("LoadLogFileAsync(int generation)", cs); - Assert.Contains("_detailMode != DetailMode.Log || _detailGeneration != generation", cs); Assert.Matches( - new Regex( - @"OnDetailRefresh[\s\S]{0,200}_detailGeneration\+\+[\s\S]{0,120}LoadLogFileAsync\(_detailGeneration\)"), - cs); + new System.Text.RegularExpressions.Regex( + @"x:Name=""MainView""[\s\S]{0,500}[\s\S]{0,300}", cs); + Assert.Contains("hwndProvider", cs); + Assert.DoesNotContain("SaveToDesktopAsync", cs); + Assert.Contains("DiagnosticsBundleDialog save skipped: no host hwnd", cs); + // It must NOT auto-close on Copy — the user may want to also save. + Assert.Contains("args.Cancel = true", cs); + } + + [Fact] + public void AppLogger_LogsFullExceptionDetails() + { + var cs = Read("src", "OpenClaw.Tray.WinUI", "AppLogger.cs"); + Assert.Contains(@"{message}: {ex}", cs); + Assert.DoesNotContain(@"{message}: {ex.Message}", cs); + } + + [Fact] + public void AboutPage_CopySupportContext_UsesUnifiedHelper() + { + var cs = Read("src", "OpenClaw.Tray.WinUI", "Pages", "AboutPage.xaml.cs"); + // Plan §4 / rubber-duck v2 #7: AboutPage's Copy Support Context + // must call the same CommandCenterTextHelper.BuildSupportContext + // that Diagnostics uses, not its old hand-rolled local string. + Assert.Contains("CommandCenterTextHelper.BuildSupportContext", cs); + Assert.Contains("DiagnosticsExportSanitizer.SanitizeTextBlock(context)", cs); + // And there must be a hyperlink that takes the user from About + // to the richer Diagnostics surface. + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "AboutPage.xaml"); + Assert.Contains("OnMoreDiagnosticsClick", xaml); + } + + [Fact] + public void HubWindow_DebugNavItem_RoutesUnchanged_LabelRenamed() + { + // The Tag must still be "debug" so command-palette / deep-link + // aliases keep working, even though the visible label is now + // "Diagnostics". + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml"); + Assert.Contains("Tag=\"debug\"", xaml); + + var resw = Read("src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw"); + Assert.Contains("", navEntryStart, StringComparison.Ordinal); + var entry = resw.Substring(navEntryStart, navEntryEnd - navEntryStart); + Assert.Contains("Diagnostics", entry); + + // Internal route mapping unchanged. + var cs = Read("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs"); + Assert.Contains("\"debug\" => typeof(DebugPage)", cs); + } + + [Fact] + public void HubWindow_NavPaneToggle_LivesInTitleBarAndHidesBuiltInToggle() + { + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml"); + Assert.Contains("x:Uid=\"NavPaneToggleButton\"", xaml); + Assert.Contains("x:Name=\"NavPaneToggleButton\"", xaml); + Assert.Contains("Click=\"OnNavPaneToggleButtonClick\"", xaml); + Assert.Contains("AutomationProperties.Name=\"Toggle navigation pane\"", xaml); + Assert.Contains("ToolTipService.ToolTip=\"Toggle navigation pane\"", xaml); + Assert.Contains("MinWidth=\"32\" MinHeight=\"32\"", xaml); + Assert.Contains("Padding=\"9,0,140,0\"", xaml); + Assert.Contains("Background=\"Transparent\"", xaml); + Assert.Contains("BorderBrush=\"Transparent\"", xaml); + Assert.Contains("BorderThickness=\"0\"", xaml); + Assert.Contains("FontSize=\"16\"", xaml); + Assert.Contains("Text=\"\ud83e\udd9e\" FontSize=\"18\"", xaml); + Assert.Contains("Translation=\"0,-1,0\"", xaml); + Assert.Contains("IsPaneToggleButtonVisible=\"False\"", xaml); + Assert.Contains("x:Name=\"NavContentHost\"", xaml); + Assert.Contains("x:Name=\"NavContentClip\"", xaml); + Assert.Contains("SizeChanged=\"OnNavContentHostSizeChanged\"", xaml); + Assert.DoesNotContain("x:Name=\"TitleContentDivider\"", xaml); + + var titleBarIndex = xaml.IndexOf("x:Name=\"AppTitleBar\"", StringComparison.Ordinal); + var toggleIndex = xaml.IndexOf("x:Name=\"NavPaneToggleButton\"", StringComparison.Ordinal); + var iconIndex = xaml.IndexOf("Text=\"\ud83e\udd9e\"", StringComparison.Ordinal); + var navViewIndex = xaml.IndexOf("x:Name=\"NavView\"", StringComparison.Ordinal); + Assert.True(titleBarIndex >= 0, "The hub title bar must exist."); + Assert.True(toggleIndex > titleBarIndex, "The nav pane toggle must live inside the title bar block."); + Assert.True(toggleIndex < iconIndex, "The nav pane toggle must appear before the app icon/title."); + Assert.True(toggleIndex < navViewIndex, "The nav pane toggle must be outside the NavigationView pane."); + + var cs = Read("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs"); + Assert.Contains("private void OnNavPaneToggleButtonClick", cs); + Assert.Contains("NavView.IsPaneOpen = !NavView.IsPaneOpen;", cs); + Assert.Contains("private void OnNavContentHostSizeChanged", cs); + Assert.Contains("NavContentClip.Rect = new global::Windows.Foundation.Rect(0, 0, e.NewSize.Width, e.NewSize.Height);", cs); + } + + [Fact] + public void DebugPage_ObservesAppState_NotHubWindow() + { + // After Ranjesh's single-app-model rebase, the page must + // observe AppState directly per + // docs/DATA_FLOW_ARCHITECTURE.md and not depend on HubWindow. + var cs = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + Assert.Contains("private static App CurrentApp", cs); + Assert.Contains("AppState? _appState", cs); + Assert.Contains("_appState.PropertyChanged", cs); + // App provides BuildCommandCenterState() so the bundle preview + // dialog can render text without going through HubWindow. + var app = Read("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); + Assert.Contains("internal GatewayCommandCenterState BuildCommandCenterState", app); + // HubWindow no longer plumbs a state-action callback for pages. + var hub = Read("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs"); + Assert.DoesNotContain("GetCommandCenterStateAction", hub); + } + + [Fact] + public void DebugPage_RefreshesOnSettingsChanged() + { + // The Status InfoBar shows the effective Gateway URL from + // SettingsManager. Settings-saved events must update the page + // immediately rather than waiting for the next Status flip + // (reactive-by-default ethos per docs/DATA_FLOW_ARCHITECTURE.md). + var cs = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + Assert.Contains("CurrentApp.SettingsChanged += OnSettingsChanged", cs); + Assert.Contains("CurrentApp.SettingsChanged -= OnSettingsChanged", cs); + Assert.Contains("OnSettingsChanged", cs); } [Fact] @@ -254,14 +481,45 @@ public void CommandCenterTextHelper_SupportContext_AdvertisesRedaction() } [Fact] - public void CommandCenterTextHelper_DebugBundle_IncludesSanitizedTrayLogTail() + public void CommandCenterTextHelper_DebugBundle_IsSummaryOnlyWithoutLogTail() { var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "CommandCenterTextHelper.cs"); + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml"); + var resources = Read("src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw"); + + Assert.DoesNotContain("Recent Tray Log", helper); + Assert.DoesNotContain("DiagnosticsLogTailReader.BuildSection", helper); + Assert.DoesNotContain("DiagnosticsTailOptions", helper); + Assert.DoesNotContain("RecentTrayLogTailLines", helper); + Assert.DoesNotContain("RecentTrayLogMaxChars", helper); + Assert.DoesNotContain("BuildRecentTrayLogTail", helper); + Assert.DoesNotContain("builder.AppendLine(line)", helper); + Assert.Contains("Generated summaries only; excludes log tails.", xaml); + Assert.Contains("Generated summaries only; excludes log tails.", resources); + Assert.DoesNotContain("sanitized recent tray log", xaml); + Assert.DoesNotContain("sanitized recent tray log", resources); + } - Assert.Contains("Recent Tray Log", helper); - Assert.Contains("BuildRecentTrayLogTail(Logger.LogFilePath)", helper); - Assert.Contains("TokenSanitizer.SanitizeLogMessage(line)", helper); - Assert.Contains("FileShare.ReadWrite | FileShare.Delete", helper); + [Fact] + public void DiagnosticsCopyAndExport_UseSideEffectFreeExportSanitizer() + { + var page = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + var service = Read("src", "OpenClaw.Tray.WinUI", "Services", "DiagnosticsClipboardService.cs"); + var builder = Read("src", "OpenClaw.Tray.WinUI", "Services", "DiagnosticsBundleBuilder.cs"); + + Assert.Contains("DiagnosticsExportSanitizer.SanitizeTextBlock", page); + Assert.Contains("DiagnosticsExportSanitizer.SanitizeTextBlock", service); + Assert.Contains( + "DiagnosticsExportSanitizer.SanitizeTextBlock(_plainBuffer.ToString())", + Read("src", "OpenClaw.Tray.WinUI", "Windows", "ConnectionStatusWindow.xaml.cs")); + Assert.Contains("ReadSanitizedTail", page); + Assert.DoesNotContain("ReadLogTail(", page); + Assert.Contains("CommandCenterTextHelper.BuildDebugBundle", page); + Assert.Contains("CommandCenterTextHelper.BuildDebugBundle", service); + Assert.DoesNotContain("DiagnosticsBundleBuilder.Build(state)", service); + Assert.DoesNotContain("NormalizePersistedDiagnosticsLogs", builder); + Assert.DoesNotContain("File.WriteAllLines", builder); + Assert.Contains("Export is read-only", builder); } [Fact] @@ -296,12 +554,52 @@ public void TrayLogWriters_SanitizeSensitiveValuesBeforeWriting() Assert.Contains("TokenSanitizer.SanitizeLogMessage(message)", logger); var diagnosticsJsonl = Read("src", "OpenClaw.Tray.WinUI", "Services", "DiagnosticsJsonlService.cs"); - Assert.Contains("TokenSanitizer.SanitizeLogMessage(JsonSerializer.Serialize(record))", diagnosticsJsonl); + Assert.Contains("FormatRecordLine(eventName, metadata)", diagnosticsJsonl); + Assert.Contains("TokenSanitizer.SanitizeLogMessage(JsonSerializer.Serialize(record, JsonOptions))", diagnosticsJsonl); var crashLogger = Read("src", "OpenClaw.Tray.WinUI", "Services", "AppCrashLogger.cs"); Assert.Contains("TokenSanitizer.SanitizeLogMessage", crashLogger); } + [Fact] + public void FullDiagnosticsBundle_IsUsedForPreviewOnly() + { + var page = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + Assert.Contains("OnCreateDiagnosticsBundle", page); + Assert.Contains("DiagnosticsBundleBuilder.Build", page); + Assert.Contains("ShowBundlePreviewAsync", page); + + var handlerStart = page.IndexOf("private void OnCopyDebugBundle", StringComparison.Ordinal); + Assert.True(handlerStart >= 0, "OnCopyDebugBundle must exist."); + var handlerBody = page.Substring(handlerStart, Math.Min(260, page.Length - handlerStart)); + + Assert.Contains("CommandCenterTextHelper.BuildDebugBundle", handlerBody); + Assert.DoesNotContain("DiagnosticsBundleBuilder.BuildCached", handlerBody); + } + + [Fact] + public void DebugPage_DetailView_UsesGenerationCounterForRaceSafety() + { + // Hanselman v2 review #5/#6: long log reads must check a + // generation counter after their async continuation so a + // page navigation mid-flight can't clobber the active view + // (or, post-popup, write into a no-longer-current generation). + var cs = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + Assert.Contains("_detailGeneration", cs); + // LoadLogFileAsync takes the generation as a parameter. + Assert.Contains("LoadLogFileAsync(int generation)", cs); + // Log mode re-checks both mode AND generation after the + // background sanitized tail read returns. + Assert.Contains("_detailMode != DetailMode.Log || _detailGeneration != generation", cs); + // Manual refresh must also invalidate any in-flight read, otherwise + // rapid refresh clicks can let multiple reads append duplicate rows + // into the same detail view generation. + Assert.Matches( + new System.Text.RegularExpressions.Regex( + @"OnDetailRefresh[\s\S]{0,200}_detailGeneration\+\+[\s\S]{0,120}LoadLogFileAsync\(_detailGeneration\)"), + cs); + } + [Fact] public void App_GetHubWindowHandle_GuardsAgainstClosedWindow() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index ae5616d0b..adafac561 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -43,6 +43,10 @@ + + + + @@ -66,6 +70,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs index 7f0c451a9..4cf8cd3e0 100644 --- a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs +++ b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs @@ -170,6 +170,72 @@ public async Task CacheToolMeta_ConcurrentAdds_FlushesCompleteValidJson() Assert.Empty(Directory.EnumerateFiles(tempDir.DirectoryPath, "*.tmp")); } + [Fact] + public async Task CacheToolMeta_PersistsReadableJsonWithoutUnicodeOrNewlineEscapes() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + var bridge = new FakeBridge + { + History = new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "session-1" + } + }; + var provider = new OpenClawChatDataProvider(bridge, post: null, toolMetaCacheFilePath: cachePath); + await provider.LoadHistoryAsync("main"); + + provider.CacheToolMeta( + "main", + 1_000, + "bash", + "exec search \"duplicate\" -> {\"timestamp\":\"2025-01-01T00:00:00+00:00\",\"message\":\"line1\r\n line2\"}"); + + await provider.DisposeAsync(); + + var json = File.ReadAllText(cachePath); + var cache = JsonSerializer.Deserialize>>(json); + var entry = Assert.Single(cache!["session-1"]); + + Assert.DoesNotContain("\\u0022", json, StringComparison.Ordinal); + Assert.DoesNotContain("\\u002B", json, StringComparison.Ordinal); + Assert.DoesNotContain("\\r\\n", json, StringComparison.Ordinal); + Assert.Contains("+00:00", json, StringComparison.Ordinal); + Assert.Contains("\\\"duplicate\\\"", json, StringComparison.Ordinal); + Assert.DoesNotContain('\r', entry.Label); + Assert.DoesNotContain('\n', entry.Label); + Assert.Contains("line1 line2", entry.Label, StringComparison.Ordinal); + } + + [Fact] + public async Task Constructor_DoesNotRewriteLegacyEscapedToolMetaCache() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + const string legacyJson = """ + { + "session-1": [ + { + "Ts": 1000, + "ToolName": "bash", + "Label": "exec \u0022duplicate\u0022 at 2025-01-01T00:00:00\u002B00:00\r\n next line" + } + ] + } + """; + File.WriteAllText(cachePath, legacyJson); + + var provider = new OpenClawChatDataProvider(new FakeBridge(), post: null, toolMetaCacheFilePath: cachePath); + await provider.DisposeAsync(); + + var json = File.ReadAllText(cachePath); + Assert.Equal(legacyJson, json); + Assert.Contains("\\u0022", json, StringComparison.Ordinal); + Assert.Contains("\\u002B", json, StringComparison.Ordinal); + Assert.Contains("\\r\\n", json, StringComparison.Ordinal); + } + [Fact] public async Task CacheToolMeta_WithoutSessionId_FallsBackToThreadKey() { @@ -191,6 +257,18 @@ public async Task CacheToolMeta_WithoutSessionId_FallsBackToThreadKey() Assert.Equal("echo after reset", entry.Label); } + [Fact] + public void TryMatch_NormalizesLegacyCachedNewlines() + { + var cache = new Queue(); + cache.Enqueue(Meta(100, "bash\r\nname", "line1\r\n \"line2\"")); + + var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200); + + Assert.Equal("bash name", result!.ToolName); + Assert.Equal("line1 \"line2\"", result.Label); + } + [Fact] public async Task Reset_DoesNotReseedClearedSessionIdFromStaleSessionsList() { @@ -230,6 +308,50 @@ public async Task Reset_DoesNotReseedClearedSessionIdFromStaleSessionsList() Assert.Equal("echo after reset", Assert.Single(entries!).Label); } + [Fact] + public async Task Reset_PersistsClearedToolMetaWhenCacheWasClean() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + const string initialJson = """ + { + "old-session": [ + { + "Ts": 1000, + "ToolName": "bash", + "Label": "stale tool" + } + ] + } + """; + File.WriteAllText(cachePath, initialJson); + var bridge = new FakeBridge + { + History = new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "old-session" + } + }; + var provider = new OpenClawChatDataProvider(bridge, post: null, toolMetaCacheFilePath: cachePath); + await provider.LoadHistoryAsync("main"); + + bridge.RaiseSessionCommandCompleted(new SessionCommandResult + { + Method = "sessions.reset", + Ok = true, + Key = "main" + }); + await provider.DisposeAsync(); + + var json = File.ReadAllText(cachePath); + var cache = JsonSerializer.Deserialize>>(json); + + Assert.NotEqual(initialJson, json); + Assert.NotNull(cache); + Assert.DoesNotContain("old-session", cache!.Keys); + } + private sealed class FakeBridge : IChatGatewayBridge { public bool IsConnected { get; set; }