From 61a6c44eb7d07d1e02087244fe92beeec1f4b3fc Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Wed, 27 May 2026 14:39:12 -0400 Subject: [PATCH 1/3] feat: add sanitized diagnostics log bundle - Add diagnostics export redactor for tokens, IDs, paths, cookies, webhooks, and provider secrets - Include sanitized tray, JSONL, crash, setup, and connection event log tails in diagnostics bundles - Replace diagnostics save with native Win32 save dialog for self-hosted WinUI - Add regression tests for redaction and bundle safety Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DiagnosticsExportRedactor.cs | 240 ++++++++++++++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 7 +- .../Helpers/Win32FilePickerHelper.cs | 119 ++++++++- .../Pages/DebugPage.xaml.cs | 6 +- .../Services/DiagnosticsBundleBuilder.cs | 163 ++++++++++++ .../Services/DiagnosticsClipboardService.cs | 9 +- .../Services/DiagnosticsLogTailReader.cs | 86 +++++++ .../Strings/en-us/Resources.resw | 15 +- .../Strings/fr-fr/Resources.resw | 15 +- .../Strings/nl-nl/Resources.resw | 15 +- .../Strings/zh-cn/Resources.resw | 15 +- .../Strings/zh-tw/Resources.resw | 15 +- .../Windows/DiagnosticsBundleDialog.xaml | 44 +++- .../Windows/DiagnosticsBundleDialog.xaml.cs | 95 ++++--- .../DiagnosticsExportRedactorTests.cs | 138 ++++++++++ .../DiagnosticsBundleBuilderTests.cs | 134 ++++++++++ .../OpenClaw.Tray.Tests.csproj | 5 + 17 files changed, 1053 insertions(+), 68 deletions(-) create mode 100644 src/OpenClaw.Shared/DiagnosticsExportRedactor.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/DiagnosticsBundleBuilder.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/DiagnosticsLogTailReader.cs create mode 100644 tests/OpenClaw.Shared.Tests/DiagnosticsExportRedactorTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/DiagnosticsBundleBuilderTests.cs diff --git a/src/OpenClaw.Shared/DiagnosticsExportRedactor.cs b/src/OpenClaw.Shared/DiagnosticsExportRedactor.cs new file mode 100644 index 000000000..2651b5dc8 --- /dev/null +++ b/src/OpenClaw.Shared/DiagnosticsExportRedactor.cs @@ -0,0 +1,240 @@ +using System.Text.RegularExpressions; + +namespace OpenClaw.Shared; + +/// +/// Redacts sensitive data from diagnostics text before it is shown in the +/// shareable bundle preview. This intentionally over-redacts: diagnostics need +/// enough shape to debug failures, not enough detail to replay credentials. +/// +public static class DiagnosticsExportRedactor +{ + private static readonly Regex PrivateKeyPattern = new( + @"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex AuthorizationBearerPattern = new( + @"(?i)(Authorization\s*:\s*Bearer\s+)([^\s""',;]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex JsonSecretFieldPattern = new( + @"""(?[^""]*(?:token|secret|bearer|authorization|password|api[_-]?key|setup[_-]?code|private[_-]?key|nonce|device[_-]?id|session[_-]?key|request[_-]?id|raw[_-]?error[_-]?response|webhook|signing|nsec|bot[_-]?token|client[_-]?secret|cookie|set[_-]?cookie|x[_-]?api[_-]?key|browser[_-]?password|relay[_-]?url)[^""]*)""\s*:\s*""(?[^""]+)""", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex KeyValueSecretPattern = new( + @"\b(?[A-Za-z0-9_.-]*(?:token|password|secret|api[_-]?key|setup[_-]?code|authorization|private[_-]?key|dpapi|nonce|device[_-]?id|session[_-]?key|request[_-]?id|raw[_-]?error[_-]?response|webhook|signing|nsec|bot[_-]?token|client[_-]?secret|cookie|set[_-]?cookie|x[_-]?api[_-]?key|browser[_-]?password|relay[_-]?url)[A-Za-z0-9_.-]*\s*[:=]\s*)(?""[^""]*""|'[^']*'|[^\s,;}\]]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex CommandLineSecretOptionPattern = new( + @"(?i)(?(?:^|\s)--(?:token|mcp-token|bootstrap-token|setup-code|password|secret|api-key|webhook|signing-secret|bot-token|client-secret|cookie|nsec)\s+)(?""[^""]*""|'[^']*'|[^\s]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex HeaderSecretPattern = new( + @"(?im)^(?\s*(?:Cookie|Set-Cookie|X-Api-Key|X-OpenClaw-Token|Proxy-Authorization)\s*:\s*)(?.+)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex NostrPrivateKeyPattern = new( + @"\bnsec1[023456789acdefghjklmnpqrstuvwxyz]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex SlackSigningSecretPattern = new( + @"\b[a-f0-9]{32}\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex DpapiBlobPattern = new( + @"\bdpapi:[A-Za-z0-9+/=_-]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex JwtPattern = new( + @"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex UrlPattern = new( + @"\b[a-z][a-z0-9+.-]*://[^\s<>""')]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex SignedHandshakePattern = new( + @"(?i)(signed:\s*)v3\|[^\r\n]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex Ed25519DeviceIdentityPattern = new( + @"(?i)(Loaded Ed25519 device identity:\s*)[^\s,;}\]""']+", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex AgentSessionKeyPattern = new( + @"\bagent:[A-Za-z0-9_.-]+:[^\s,;}\]""']+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex LabelledNodeIdPattern = new( + @"(?i)(\bnode:\s*)[A-Za-z0-9._-]{8,}(?:\.\.\.)?", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex ChatCorrelationIdPattern = new( + @"(?i)(\b(?:id|OpenClawId)\s*=\s*['""]?)[A-Fa-f0-9]{8,16}(['""]?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex WindowsUserPathPattern = new( + @"\b[A-Za-z]:\\Users\\[^\\\r\n""']+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex EscapedWindowsUserPathPattern = new( + @"\b[A-Za-z]:\\\\Users\\\\[^\\\r\n""']+", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex UnixUserPathPattern = new( + @"/Users/[^/\s]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex EmailPattern = new( + @"\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex UserAtHostPattern = new( + @"\b(?[A-Za-z0-9._-]+)@(?[A-Za-z0-9._-]+)(?=[:\s]|$)", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex IpPattern = new( + @"\b(?:\d{1,3}\.){3}\d{1,3}\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static readonly Regex HexTokenPattern = new( + @"(? $"\"{match.Groups["key"].Value}\":\"[REDACTED]\""); + sanitized = KeyValueSecretPattern.Replace( + sanitized, + match => $"{match.Groups["prefix"].Value}[REDACTED]"); + sanitized = CommandLineSecretOptionPattern.Replace( + sanitized, + match => $"{match.Groups["prefix"].Value}[REDACTED]"); + sanitized = DpapiBlobPattern.Replace(sanitized, "dpapi:[REDACTED]"); + sanitized = SignedHandshakePattern.Replace(sanitized, "$1[REDACTED_HANDSHAKE]"); + sanitized = Ed25519DeviceIdentityPattern.Replace(sanitized, "$1[REDACTED_DEVICE_ID]"); + sanitized = AgentSessionKeyPattern.Replace(sanitized, "[REDACTED_SESSION_KEY]"); + sanitized = LabelledNodeIdPattern.Replace(sanitized, "$1[REDACTED_NODE_ID]"); + sanitized = ChatCorrelationIdPattern.Replace(sanitized, "$1[REDACTED_ID]$2"); + sanitized = NostrPrivateKeyPattern.Replace(sanitized, "[REDACTED_NSEC]"); + sanitized = JwtPattern.Replace(sanitized, "[REDACTED_JWT]"); + sanitized = UrlPattern.Replace(sanitized, match => SanitizeUrl(match.Value)); + sanitized = EmailPattern.Replace(sanitized, ""); + sanitized = UserAtHostPattern.Replace(sanitized, "@"); + sanitized = IpPattern.Replace(sanitized, ""); + sanitized = GuidPattern.Replace(sanitized, "[REDACTED_ID]"); + sanitized = HexTokenPattern.Replace(sanitized, "[REDACTED_TOKEN]"); + sanitized = SlackSigningSecretPattern.Replace(sanitized, "[REDACTED_TOKEN]"); + return LongBase64Pattern.Replace(sanitized, "[REDACTED_TOKEN]"); + } + + public static string RedactPath(string? pathOrText) + { + if (string.IsNullOrEmpty(pathOrText)) + return pathOrText ?? string.Empty; + + var redacted = pathOrText; + foreach (var (folder, replacement) in KnownFolderReplacements()) + { + if (string.IsNullOrWhiteSpace(folder)) + continue; + + redacted = redacted.Replace(folder, replacement, StringComparison.OrdinalIgnoreCase); + } + + redacted = WindowsUserPathPattern.Replace(redacted, match => + { + var value = match.Value; + var prefixLength = value.IndexOf(@"\Users\", StringComparison.OrdinalIgnoreCase); + return prefixLength >= 0 + ? value[..(prefixLength + @"\Users\".Length)] + "" + : "%USERPROFILE%"; + }); + + redacted = EscapedWindowsUserPathPattern.Replace(redacted, match => + { + var value = match.Value; + var prefixLength = value.IndexOf(@"\\Users\\", StringComparison.OrdinalIgnoreCase); + return prefixLength >= 0 + ? value[..(prefixLength + @"\\Users\\".Length)] + "" + : "%USERPROFILE%"; + }); + + return UnixUserPathPattern.Replace(redacted, "$HOME"); + } + + private static IEnumerable<(string Folder, string Replacement)> KnownFolderReplacements() + { + yield return ( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "%USERPROFILE%"); + yield return ( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "%APPDATA%"); + yield return ( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "%LOCALAPPDATA%"); + yield return ( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + @"%USERPROFILE%\Documents"); + } + + private static string SanitizeUrl(string raw) + { + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) + return ""; + + var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}"; + var path = uri.AbsolutePath; + var firstSegment = string.Empty; + if (!string.IsNullOrWhiteSpace(path) && path != "/") + { + var secondSlash = path.IndexOf('/', 1); + firstSegment = secondSlash < 0 ? path : path[..secondSlash] + "/…"; + } + + return $"{uri.Scheme}://{port}{firstSegment}"; + } +} diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 66d0ec9f7..13d2df169 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -442,7 +442,9 @@ _dispatcherQueue is null _gatewayService.NotificationReceived += OnGatewayNotificationReceived; _appState.PropertyChanged += OnAppStateChanged; - _diagnosticsClipboard = new DiagnosticsClipboardService(BuildCommandCenterState); + _diagnosticsClipboard = new DiagnosticsClipboardService( + BuildCommandCenterState, + GetConnectionDiagnosticEvents); _toastService = new ToastService(() => _settings); DiagnosticsJsonlService.Write("app.start", new @@ -2886,6 +2888,9 @@ private void UpdateStatusDetailWindow() internal GatewayCommandCenterState BuildCommandCenterState() => new CommandCenterStateBuilder(CaptureSnapshot()).Build(); + internal IReadOnlyList GetConnectionDiagnosticEvents() => + _connectionManager?.Diagnostics.GetRecent(200) ?? []; + private AppStateSnapshot CaptureSnapshot() => new AppStateSnapshot { Status = _appState!.Status, diff --git a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs index 58b6744d4..a75e24744 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs @@ -1,17 +1,19 @@ using System; 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 { /// @@ -49,11 +51,70 @@ internal static class Win32FilePickerHelper 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.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 @@ -86,6 +147,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("43826D1E-E718-42EE-BC55-A1E261C37BFE")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IShellItem @@ -102,6 +213,8 @@ private enum FOS : uint { FOS_FORCEFILESYSTEM = 0x40, FOS_FILEMUSTEXIST = 0x1000, + FOS_PATHMUSTEXIST = 0x800, + FOS_OVERWRITEPROMPT = 0x2, } private enum SIGDN : uint diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs index d16580aad..56d217575 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs @@ -410,9 +410,9 @@ private async void OnCreateDiagnosticsBundle(object sender, RoutedEventArgs e) { await ShowBundlePreviewAsync( title: "Diagnostics bundle", - buildText: CommandCenterTextHelper.BuildDebugBundle, + buildText: state => DiagnosticsBundleBuilder.Build(state, CurrentApp.GetConnectionDiagnosticEvents()), suggestedFileName: $"openclaw-diagnostics-{DateTime.Now:yyyyMMdd-HHmmss}.txt", - headerCaption: "This is the complete bundle that would be copied or saved."); + headerCaption: "This is the complete sanitized bundle that would be copied or saved. Review before sharing."); } private async Task ShowBundlePreviewAsync( @@ -462,7 +462,7 @@ 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("Debug bundle", state => DiagnosticsBundleBuilder.Build(state, CurrentApp.GetConnectionDiagnosticEvents())); private void OnCopyBrowserSetup(object sender, RoutedEventArgs e) => CopyDiagnosticText("Browser setup guidance", CommandCenterTextHelper.BuildBrowserSetupGuidance); diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsBundleBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsBundleBuilder.cs new file mode 100644 index 000000000..2ce722574 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsBundleBuilder.cs @@ -0,0 +1,163 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClawTray.Helpers; +using System.Text; + +namespace OpenClawTray.Services; + +internal sealed record DiagnosticsBundlePaths( + string? TrayLogPath, + string? TrayLogArchivePath, + string? DiagnosticsJsonlPath, + string? CrashLogPath, + string? SetupLogDirectory) +{ + public static DiagnosticsBundlePaths Default() + { + var trayLog = Logger.LogFilePath; + var logDirectory = Path.GetDirectoryName(trayLog); + var diagnosticsJsonl = DiagnosticsJsonlService.FilePath ?? + Path.Combine(logDirectory ?? "", "Logs", "diagnostics.jsonl"); + return new DiagnosticsBundlePaths( + TrayLogPath: trayLog, + TrayLogArchivePath: string.IsNullOrWhiteSpace(logDirectory) + ? null + : Path.Combine(logDirectory, "openclaw-tray.log.old"), + DiagnosticsJsonlPath: diagnosticsJsonl, + CrashLogPath: string.IsNullOrWhiteSpace(logDirectory) + ? null + : Path.Combine(logDirectory, "crash.log"), + SetupLogDirectory: Path.Combine(SettingsManager.SettingsDirectoryPath, "Logs", "Setup")); + } +} + +internal static class DiagnosticsBundleBuilder +{ + private static readonly DiagnosticsTailOptions StandardTail = new(MaxLines: 200); + private static readonly DiagnosticsTailOptions ShortTail = new(MaxLines: 120); + + 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("Redaction:"); + builder.AppendLine("- Tokens, bootstrap/shared credentials, bearer headers, API keys, passwords, setup codes, DPAPI blobs, private keys, URLs, emails, IPs, and user paths are sanitized."); + 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("- Long sections are truncated and marked inline."); + 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(); + + AppendSection(builder, "Generated Debug Summary", CommandCenterTextHelper.BuildDebugBundle(state)); + AppendSection(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, StandardTail)); + builder.Append(DiagnosticsLogTailReader.BuildSection("Crash Log Tail", paths.CrashLogPath, ShortTail)); + AppendLatestSetupLogs(builder, paths.SetupLogDirectory); + + return DiagnosticsExportRedactor.Sanitize(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" + : DiagnosticsExportRedactor.RedactPath(path)); + } + + private static void AppendSection(StringBuilder builder, string title, string content) + { + builder.AppendLine($"## {title}"); + builder.AppendLine(DiagnosticsExportRedactor.Sanitize(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(evt.Category); + builder.Append("] "); + builder.Append(evt.Message); + if (!string.IsNullOrWhiteSpace(evt.Detail)) + { + builder.Append(" — "); + builder.Append(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; + } + + var latestLogs = Directory.EnumerateFiles(setupLogDirectory, "*.jsonl", SearchOption.TopDirectoryOnly) + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .Take(4) + .ToList(); + + if (latestLogs.Count == 0) + { + builder.AppendLine("Status: no setup logs found"); + builder.AppendLine(); + return; + } + + builder.AppendLine(); + foreach (var file in latestLogs) + { + builder.Append(DiagnosticsLogTailReader.BuildSection( + $"Setup Log Tail: {file.Name}", + file.FullName, + ShortTail)); + } + } + + private static string FormatPath(string? path) => + string.IsNullOrWhiteSpace(path) + ? "not configured" + : DiagnosticsExportRedactor.RedactPath(path); +} diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs index 29b657091..73891372d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs @@ -1,5 +1,6 @@ using OpenClaw.Shared; using OpenClawTray.Helpers; +using OpenClaw.Connection; using System; namespace OpenClawTray.Services; @@ -11,10 +12,14 @@ namespace OpenClawTray.Services; internal sealed class DiagnosticsClipboardService { private readonly Func _captureState; + private readonly Func> _captureConnectionEvents; - public DiagnosticsClipboardService(Func captureState) + public DiagnosticsClipboardService( + Func captureState, + Func>? captureConnectionEvents = null) { _captureState = captureState; + _captureConnectionEvents = captureConnectionEvents ?? (() => []); } public void CopyDiagnostic(string label, Func format) @@ -34,7 +39,7 @@ public void CopySupportContext() => CopyDiagnostic("support context", CommandCenterTextHelper.BuildSupportContext); public void CopyDebugBundle() => - CopyDiagnostic("debug bundle", CommandCenterTextHelper.BuildDebugBundle); + CopyDiagnostic("debug bundle", state => DiagnosticsBundleBuilder.Build(state, _captureConnectionEvents())); public void CopyBrowserSetupGuidance() => CopyDiagnostic("browser setup guidance", CommandCenterTextHelper.BuildBrowserSetupGuidance); diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsLogTailReader.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsLogTailReader.cs new file mode 100644 index 000000000..81ebd8f29 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsLogTailReader.cs @@ -0,0 +1,86 @@ +using OpenClaw.Shared; +using System.Text; + +namespace OpenClawTray.Services; + +internal sealed record DiagnosticsTailOptions( + int MaxLines = 200, + int MaxLineChars = 8_000, + int MaxSectionChars = 256_000); + +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 = ReadTail(path, options.MaxLines); + builder.AppendLine($"Lines: last {lines.Count} of up to {options.MaxLines}"); + builder.AppendLine(); + + var writtenChars = 0; + foreach (var rawLine in lines) + { + var line = DiagnosticsExportRedactor.Sanitize(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 ({DiagnosticsExportRedactor.Sanitize(ex.Message)})"); + } + + builder.AppendLine(); + return builder.ToString(); + } + + public static IReadOnlyList ReadTail(string path, int maxLines) + { + if (maxLines <= 0) + return []; + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + var queue = new Queue(maxLines); + string? line; + while ((line = reader.ReadLine()) != null) + { + if (queue.Count == maxLines) + queue.Dequeue(); + queue.Enqueue(line); + } + return queue.ToArray(); + } + + private static string FormatPath(string? path) => + string.IsNullOrWhiteSpace(path) + ? "not configured" + : DiagnosticsExportRedactor.RedactPath(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]"; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 9d4a9a5d6..e466f5bc2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4563,9 +4563,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 @@ -4577,4 +4574,14 @@ 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 + + + Review before sharing + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index ef2a53b63..34354b8d0 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -4515,9 +4515,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é @@ -4529,4 +4526,14 @@ 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 + + + Examiner avant de partager + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 148b26e27..1100ee409 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -4516,9 +4516,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 @@ -4530,4 +4527,14 @@ 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 + + + Controleer voor het delen + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index f363ac4b2..b55314d78 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -4515,9 +4515,6 @@ 分享前请审阅 - - 捆绑包生成器会排除敏感令牌、命令参数、负载和录制内容。 - 已断开连接 @@ -4529,4 +4526,14 @@ 高级 - + + + 日志尾部会经过清理并截断。敏感令牌、负载、录制内容和原始设置文件会被排除。共享前请先检查。 + + + 分享前请检查 + + + 分享前请检查 + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index fc36d5e96..f5d7e975d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -4515,9 +4515,6 @@ 分享前請檢閱 - - 套件產生器會排除敏感權杖、命令引數、內容與錄製。 - 已中斷連線 @@ -4529,4 +4526,14 @@ 進階 - + + + 記錄尾端會經過清理並截斷。敏感權杖、內容、錄製資料與原始設定檔會被排除。分享前請先檢查。 + + + 分享前請檢查 + + + 分享前請檢查 + + diff --git a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml index 53ef90679..ab67bb5bf 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"> + + + + + + + + + + + + @@ -56,3 +85,4 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs index 2f41827ff..92500fa76 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs @@ -4,9 +4,6 @@ using System; using System.IO; using System.Threading.Tasks; -using Windows.Storage; -using Windows.Storage.Pickers; -using WinRT.Interop; namespace OpenClawTray.Windows; @@ -65,57 +62,91 @@ private void OnCopyClick(ContentDialog sender, ContentDialogButtonClickEventArgs timer.Start(); } - private void OnSaveClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + private async void OnSaveClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) { // 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(); + 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(); } - 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; + var fallback = await SaveToDesktopAsync(); + return new SaveResult(fallback, "Saved to Desktop"); } - var picker = new FileSavePicker + var selectedPath = await Win32FilePickerHelper.PickSaveFileAsync( + hwnd, + title: "Save diagnostics bundle", + suggestedFileName: Path.GetFileName(_suggestedFileName), + defaultExtension: "txt"); + if (!string.IsNullOrWhiteSpace(selectedPath)) { - 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) - { - 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}"); + return new SaveResult(null, "Save failed"); + } + } + + private async Task SaveToDesktopAsync() + { + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + if (string.IsNullOrWhiteSpace(desktop)) + desktop = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + Directory.CreateDirectory(desktop); + + var baseName = Path.GetFileNameWithoutExtension(_suggestedFileName); + if (string.IsNullOrWhiteSpace(baseName)) + baseName = "openclaw-diagnostics"; + + var path = Path.Combine(desktop, baseName + ".txt"); + var suffix = 1; + while (File.Exists(path)) + { + path = Path.Combine(desktop, $"{baseName}-{suffix++}.txt"); } + + await File.WriteAllTextAsync(path, _bundleText); + System.Diagnostics.Debug.WriteLine($"DiagnosticsBundleDialog saved fallback file: {path}"); + return path; } + + private sealed record SaveResult(string? Path, string ButtonText); } diff --git a/tests/OpenClaw.Shared.Tests/DiagnosticsExportRedactorTests.cs b/tests/OpenClaw.Shared.Tests/DiagnosticsExportRedactorTests.cs new file mode 100644 index 000000000..48a9c7328 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/DiagnosticsExportRedactorTests.cs @@ -0,0 +1,138 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public sealed class DiagnosticsExportRedactorTests +{ + [Fact] + public void Sanitize_RedactsCommonSecretShapes() + { + const string privateKey = """ + -----BEGIN PRIVATE KEY----- + abcdefghijklmnopqrstuvwxyz + -----END PRIVATE KEY----- + """; + var mcpToken = new string('A', 43); + var hexToken = new string('a', 64); + var dpapi = "dpapi:abcdefghijklmnopqrstuvwxyz0123456789+/="; + + var input = string.Join(Environment.NewLine, + "Authorization: Bearer bearer-secret", + """{"sharedGatewayToken":"shared-secret","password":"pw-secret","setupCode":"setup-secret","apiKey":"api-secret"}""", + """{"nonce":"nonce-secret","deviceId":"device-secret","requestId":"request-secret","sessionKey":"agent:abc:def","raw_error_response":"raw-id-secret"}""", + $"token={mcpToken}", + $"hex={hexToken}", + $"protected={dpapi}", + "jwt=eyJaaaaaaaaaa.bbbbbbbbbb.cccccccccc", + privateKey); + + var sanitized = DiagnosticsExportRedactor.Sanitize(input); + + Assert.DoesNotContain("bearer-secret", sanitized); + Assert.DoesNotContain("shared-secret", sanitized); + Assert.DoesNotContain("pw-secret", sanitized); + Assert.DoesNotContain("setup-secret", sanitized); + Assert.DoesNotContain("api-secret", sanitized); + Assert.DoesNotContain("nonce-secret", sanitized); + Assert.DoesNotContain("device-secret", sanitized); + Assert.DoesNotContain("request-secret", sanitized); + Assert.DoesNotContain("agent:abc:def", sanitized); + Assert.DoesNotContain("raw-id-secret", sanitized); + Assert.DoesNotContain(mcpToken, sanitized); + Assert.DoesNotContain(hexToken, sanitized); + Assert.DoesNotContain(dpapi, sanitized); + Assert.DoesNotContain("eyJaaaaaaaaaa", sanitized); + Assert.DoesNotContain("BEGIN PRIVATE KEY", sanitized); + Assert.Contains("[REDACTED]", sanitized); + } + + [Fact] + public void Sanitize_RedactsUrlsPathsEmailsIps_WhileKeepingFailureContext() + { + var input = """ + Failed to connect to wss://alice:secret@gateway.example.com:18789/reset/password?token=secret#frag + File: C:\Users\christineyan\AppData\Roaming\OpenClawTray\gateways.json + EscapedFile: C:\\Users\\christineyan\\AppData\\Roaming\\OpenClawTray\\settings.json + Contact christine@example.com from 192.168.1.44 or user@host:22 + 2026-05-27T15:16:34.3474228Z [handshake] signed: v3|token|cli|cli|operator|operator.admin,operator.read|1779894994338|sig|c5cacc40-2732-4008-a4d9-56b6a2c0643a|windows|desktop + [2026-05-27 12:57:15.658] [INFO] Loaded Ed25519 device identity: 1ecac3b3e936a1e0... + 2026-05-27T13:27:28.8631055-04:00 [node] Node paired - node: 1ecac3b3e936... · dashboard: nodes + session=agent:abc123:some-session-key + [DEBUG] [IsMessageAborted] thread='agent:abc123:some-session-key' id='dee79a01' dictHasThread=False setCount=0 match=False + [DEBUG] [ChatHistory] user msg OpenClawId='2d85dba4' seq=1 + {"type":"event","payload":{"nonce":"abc","ts":1779900898148}} + Error: pairing request timed out on port 18789 + """; + + var sanitized = DiagnosticsExportRedactor.Sanitize(input); + + Assert.Contains("Failed to connect", sanitized); + Assert.Contains("pairing request timed out", sanitized); + Assert.Contains("18789", sanitized); + Assert.Contains("wss://:18789/reset", sanitized); + Assert.Contains("", sanitized); + Assert.Contains("", sanitized); + Assert.Contains("@", sanitized); + Assert.DoesNotContain("christineyan", sanitized); + Assert.DoesNotContain("gateway.example.com", sanitized); + Assert.DoesNotContain("token=secret", sanitized); + Assert.DoesNotContain("1779894994338", sanitized); + Assert.DoesNotContain("c5cacc40-2732-4008-a4d9-56b6a2c0643a", sanitized); + Assert.DoesNotContain("agent:abc123:some-session-key", sanitized); + Assert.DoesNotContain("1ecac3b3e936a1e0", sanitized); + Assert.DoesNotContain("dee79a01", sanitized); + Assert.DoesNotContain("2d85dba4", sanitized); + Assert.Contains("signed: [REDACTED_HANDSHAKE]", sanitized); + Assert.Contains("[REDACTED_SESSION_KEY]", sanitized); + Assert.Contains("node: [REDACTED_NODE_ID]", sanitized); + Assert.Contains("id='[REDACTED_ID]'", sanitized); + Assert.Contains("OpenClawId='[REDACTED_ID]'", sanitized); + Assert.Contains("\"ts\":1779900898148", sanitized); + } + + [Fact] + public void Sanitize_RedactsScaryConnectionAndChannelCredentialShapes() + { + var input = string.Join(Environment.NewLine, + """{"webhookUrl":"https://hooks.slack.com/services/T000/B000/SECRET","signingSecret":"1234567890abcdef1234567890abcdef","botToken":"telegram-secret","clientSecret":"oauth-secret","relayUrls":"wss://relay.example.com/private","browserPassword":"browser-secret"}""", + "Cookie: sessionid=browser-cookie; csrftoken=csrf-secret", + "Set-Cookie: auth=secret-cookie; Path=/", + "X-Api-Key: api-key-secret", + "Proxy-Authorization: Basic dXNlcjpwYXNz", + "openclaw connect --token shared-secret --mcp-token mcp-secret --bootstrap-token boot-secret --setup-code setup-secret --password pass-secret --webhook https://discord.com/api/webhooks/123/secret --signing-secret sign-secret --bot-token bot-secret --client-secret client-secret --nsec nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "Browser auth failed but status context remains useful"); + + var sanitized = DiagnosticsExportRedactor.Sanitize(input); + + foreach (var secret in new[] + { + "hooks.slack.com", + "telegram-secret", + "oauth-secret", + "relay.example.com", + "browser-secret", + "browser-cookie", + "csrf-secret", + "secret-cookie", + "api-key-secret", + "dXNlcjpwYXNz", + "shared-secret", + "mcp-secret", + "boot-secret", + "setup-secret", + "pass-secret", + "discord.com", + "sign-secret", + "bot-secret", + "nsec1" + }) + { + Assert.DoesNotContain(secret, sanitized, StringComparison.OrdinalIgnoreCase); + } + + Assert.Contains("Browser auth failed", sanitized); + Assert.Contains("--token [REDACTED]", sanitized); + Assert.Contains("Cookie: [REDACTED]", sanitized); + Assert.Contains("--nsec [REDACTED]", sanitized); + } +} diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsBundleBuilderTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsBundleBuilderTests.cs new file mode 100644 index 000000000..96c3fb4f0 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/DiagnosticsBundleBuilderTests.cs @@ -0,0 +1,134 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClawTray.Services; + +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, "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", + "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_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 Build_FinalSanitizationCatchesSecretsSplitAcrossLogLines() + { + var trayLog = Path.Combine(_tempDir, "openclaw-tray.log"); + File.WriteAllText(trayLog, """ + {"event":"split-secret","metadata":{"token": + "split-token-secret"}} + """); + + var bundle = DiagnosticsBundleBuilder.Build( + new GatewayCommandCenterState(), + [], + new DiagnosticsBundlePaths( + trayLog, + null, + null, + null, + null)); + + Assert.Contains("split-secret", bundle); + Assert.DoesNotContain("split-token-secret", bundle); + Assert.Contains("[REDACTED]", bundle); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index e4dbcb9a5..49bac5372 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -42,6 +42,9 @@ + + + @@ -56,6 +59,8 @@ + + From 45f9486aaeacb3a675d0b947f47d952fdb006354 Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Wed, 27 May 2026 15:34:44 -0400 Subject: [PATCH 2/3] fix: keep log-tail diagnostics behind preview - Restore direct debug-bundle copy/deep-link path to generated summaries only - Update Diagnostics page copy to clarify summary-only clipboard behavior - Add contract tests preventing log-tail bundles from bypassing preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 4 +-- src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml | 4 +-- .../Services/DiagnosticsClipboardService.cs | 8 ++---- .../Strings/en-us/Resources.resw | 4 +-- .../Strings/fr-fr/Resources.resw | 4 +-- .../Strings/nl-nl/Resources.resw | 4 +-- .../Strings/zh-cn/Resources.resw | 4 +-- .../Strings/zh-tw/Resources.resw | 4 +-- .../DiagnosticsPageContractTests.cs | 25 +++++++++++++++++++ 9 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 13d2df169..896b39cda 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -442,9 +442,7 @@ _dispatcherQueue is null _gatewayService.NotificationReceived += OnGatewayNotificationReceived; _appState.PropertyChanged += OnAppStateChanged; - _diagnosticsClipboard = new DiagnosticsClipboardService( - BuildCommandCenterState, - GetConnectionDiagnosticEvents); + _diagnosticsClipboard = new DiagnosticsClipboardService(BuildCommandCenterState); _toastService = new ToastService(() => _settings); DiagnosticsJsonlService.Write("app.start", new diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml index 57f61e3e0..a3da276d0 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml @@ -131,8 +131,8 @@ diff --git a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs index 73891372d..a960cf635 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DiagnosticsClipboardService.cs @@ -1,6 +1,5 @@ using OpenClaw.Shared; using OpenClawTray.Helpers; -using OpenClaw.Connection; using System; namespace OpenClawTray.Services; @@ -12,14 +11,11 @@ namespace OpenClawTray.Services; internal sealed class DiagnosticsClipboardService { private readonly Func _captureState; - private readonly Func> _captureConnectionEvents; public DiagnosticsClipboardService( - Func captureState, - Func>? captureConnectionEvents = null) + Func captureState) { _captureState = captureState; - _captureConnectionEvents = captureConnectionEvents ?? (() => []); } public void CopyDiagnostic(string label, Func format) @@ -39,7 +35,7 @@ public void CopySupportContext() => CopyDiagnostic("support context", CommandCenterTextHelper.BuildSupportContext); public void CopyDebugBundle() => - CopyDiagnostic("debug bundle", state => DiagnosticsBundleBuilder.Build(state, _captureConnectionEvents())); + CopyDiagnostic("summary debug bundle", CommandCenterTextHelper.BuildDebugBundle); public void CopyBrowserSetupGuidance() => CopyDiagnostic("browser setup guidance", CommandCenterTextHelper.BuildBrowserSetupGuidance); diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index e466f5bc2..f38e4b768 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -3547,10 +3547,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 diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 34354b8d0..fdd6e4afb 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -3499,10 +3499,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 diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 1100ee409..7a3640b93 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -3500,10 +3500,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 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index b55314d78..500b5dc2f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -3499,10 +3499,10 @@ Connection state, gateway URL, runtime, tunnel. - Copy full debug bundle + 复制摘要调试包 - Everything the preview dialog produces, no preview. + 仅包含生成的摘要;不包含日志尾部。请使用“创建诊断包”在共享前检查日志。 Copy browser setup guidance diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index f5d7e975d..c47e9f906 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -3499,10 +3499,10 @@ Connection state, gateway URL, runtime, tunnel. - Copy full debug bundle + 複製摘要偵錯套件 - Everything the preview dialog produces, no preview. + 僅包含產生的摘要;不包含記錄尾端。請使用「建立診斷套件」在分享前檢查記錄。 Copy browser setup guidance diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs index a0ef2c97a..b98d02237 100644 --- a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs @@ -545,6 +545,31 @@ public void CommandCenterTextHelper_SupportContext_AdvertisesRedaction() Assert.Contains("bootstrap tokens", helper); } + [Fact] + public void DirectCopyDebugBundle_RemainsSummaryOnly_NotLogTailBundle() + { + var service = Read("src", "OpenClaw.Tray.WinUI", "Services", "DiagnosticsClipboardService.cs"); + var copyDebugStart = service.IndexOf("public void CopyDebugBundle()", StringComparison.Ordinal); + Assert.True(copyDebugStart >= 0, "CopyDebugBundle must exist."); + var copyDebugBody = service.Substring(copyDebugStart, Math.Min(260, service.Length - copyDebugStart)); + + Assert.Contains("CommandCenterTextHelper.BuildDebugBundle", copyDebugBody); + Assert.DoesNotContain("DiagnosticsBundleBuilder.Build", copyDebugBody); + + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml"); + Assert.Contains("Copy summary debug bundle", xaml); + Assert.Contains("excludes log tails", xaml); + } + + [Fact] + public void FullLogTailBundle_IsOnlyBuiltForPreviewDialog() + { + var page = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + Assert.Contains("OnCreateDiagnosticsBundle", page); + Assert.Contains("DiagnosticsBundleBuilder.Build", page); + Assert.Contains("ShowBundlePreviewAsync", page); + } + [Fact] public void DebugPage_DetailView_UsesGenerationCounterForRaceSafety() { From 81a1d6a4266d8db51bb2e3201b81a7baa5e90c1f Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Wed, 27 May 2026 16:23:06 -0400 Subject: [PATCH 3/3] fix: address diagnostics bundle review comments - Keep Diagnostics page summary-copy action summary-only - Strengthen contract tests for the preview-only log-tail boundary - Destroy native save-dialog filter spec before freeing unmanaged memory - Remove unused diagnostics InfoBar localization resources - Update no-HWND save diagnostic message to match Desktop fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/Win32FilePickerHelper.cs | 3 +++ src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs | 2 +- src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw | 3 --- src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw | 3 --- src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw | 3 --- src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw | 3 --- src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw | 3 --- .../Windows/DiagnosticsBundleDialog.xaml.cs | 2 +- .../DiagnosticsPageContractTests.cs | 10 +++++++++- 9 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs index a75e24744..497732ed3 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/Win32FilePickerHelper.cs @@ -98,7 +98,10 @@ internal static class Win32FilePickerHelper finally { if (filterSpecPtr != IntPtr.Zero) + { + Marshal.DestroyStructure(filterSpecPtr); Marshal.FreeCoTaskMem(filterSpecPtr); + } } }); staThread.SetApartmentState(ApartmentState.STA); diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs index 56d217575..85af221d7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs @@ -462,7 +462,7 @@ private void OnCopySupportContext(object sender, RoutedEventArgs e) => CopyDiagnosticText("Support context", CommandCenterTextHelper.BuildSupportContext); private void OnCopyDebugBundle(object sender, RoutedEventArgs e) - => CopyDiagnosticText("Debug bundle", state => DiagnosticsBundleBuilder.Build(state, CurrentApp.GetConnectionDiagnosticEvents())); + => CopyDiagnosticText("Summary debug bundle", CommandCenterTextHelper.BuildDebugBundle); private void OnCopyBrowserSetup(object sender, RoutedEventArgs e) => CopyDiagnosticText("Browser setup guidance", CommandCenterTextHelper.BuildBrowserSetupGuidance); diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index f38e4b768..7b49787db 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4578,9 +4578,6 @@ On your gateway host (Mac/Linux), run: Log tails are sanitized and truncated. Sensitive tokens, payloads, recordings, and raw settings files are excluded. Review before sharing. - - Review before sharing - Review before sharing diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index fdd6e4afb..a773fe842 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -4530,9 +4530,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : 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 - Examiner avant de partager diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 7a3640b93..9c92c30b8 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -4531,9 +4531,6 @@ Voer op uw gateway-host (Mac/Linux) uit: Logfragmenten worden opgeschoond en ingekort. Gevoelige tokens, payloads, opnames en onbewerkte instellingenbestanden worden uitgesloten. Controleer dit voordat u deelt. - - Controleer voor het delen - Controleer voor het delen diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 500b5dc2f..cc372a9f2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -4530,9 +4530,6 @@ 日志尾部会经过清理并截断。敏感令牌、负载、录制内容和原始设置文件会被排除。共享前请先检查。 - - 分享前请检查 - 分享前请检查 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index c47e9f906..18aecea12 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -4530,9 +4530,6 @@ 記錄尾端會經過清理並截斷。敏感權杖、內容、錄製資料與原始設定檔會被排除。分享前請先檢查。 - - 分享前請檢查 - 分享前請檢查 diff --git a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs index 92500fa76..431cd45c9 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/DiagnosticsBundleDialog.xaml.cs @@ -100,7 +100,7 @@ private async Task SaveToFileAsync() var hwnd = _hwndProvider?.Invoke() ?? IntPtr.Zero; if (hwnd == IntPtr.Zero) { - System.Diagnostics.Debug.WriteLine("DiagnosticsBundleDialog save: no host hwnd; skipping picker."); + System.Diagnostics.Debug.WriteLine("DiagnosticsBundleDialog save: no host hwnd; saving to Desktop instead."); var fallback = await SaveToDesktopAsync(); return new SaveResult(fallback, "Saved to Desktop"); } diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs index b98d02237..156de1747 100644 --- a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs @@ -213,7 +213,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(\"Summary debug bundle\"", cs); Assert.Contains("CopyDiagnosticText(\"Browser setup guidance\"", cs); Assert.Contains("CopyDiagnosticText(\"Port diagnostics\"", cs); Assert.Contains("CopyDiagnosticText(\"Capability diagnostics\"", cs); @@ -556,6 +556,14 @@ public void DirectCopyDebugBundle_RemainsSummaryOnly_NotLogTailBundle() Assert.Contains("CommandCenterTextHelper.BuildDebugBundle", copyDebugBody); Assert.DoesNotContain("DiagnosticsBundleBuilder.Build", copyDebugBody); + var page = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml.cs"); + 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.Build", handlerBody); + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml"); Assert.Contains("Copy summary debug bundle", xaml); Assert.Contains("excludes log tails", xaml);