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..896b39cda 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -2886,6 +2886,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..497732ed3 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,73 @@ 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.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
@@ -86,6 +150,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 +216,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 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/Pages/DebugPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs
index d16580aad..85af221d7 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("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/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..a960cf635 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;
}
@@ -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/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..7b49787db 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
@@ -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,11 @@ 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
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index ef2a53b63..a773fe842 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
@@ -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,11 @@ 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
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 148b26e27..9c92c30b8 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
@@ -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,11 @@ 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
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index f363ac4b2..cc372a9f2 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
@@ -4515,9 +4515,6 @@
分享å‰è¯·å®¡é˜…
-
- æ†ç»‘包生æˆå™¨ä¼šæŽ’é™¤æ•æ„Ÿä»¤ç‰Œã€å‘½ä»¤å‚æ•°ã€è´Ÿè½½å’Œå½•制内容。
-
å·²æ–开连接
@@ -4529,4 +4526,11 @@
高级
-
+
+
+ 日志尾部会经过清理并截断。敏感令牌、负载、录制内容和原始设置文件会被排除。共享前请先检查。
+
+
+ 分享前请检查
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index fc36d5e96..18aecea12 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
@@ -4515,9 +4515,6 @@
分享å‰è«‹æª¢é–±
-
- å¥—ä»¶ç”¢ç”Ÿå™¨æœƒæŽ’é™¤æ•æ„Ÿæ¬Šæ–ã€å‘½ä»¤å¼•數ã€å…§å®¹èˆ‡éŒ„製。
-
已䏿–·é€£ç·š
@@ -4529,4 +4526,11 @@
進階
-
+
+
+ 記錄尾端會經過清理並截斷。敏感權杖、內容、錄製資料與原始設定檔會被排除。分享前請先檢查。
+
+
+ 分享前請檢查
+
+
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..431cd45c9 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;
+ System.Diagnostics.Debug.WriteLine("DiagnosticsBundleDialog save: no host hwnd; saving to Desktop instead.");
+ 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/DiagnosticsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
index a0ef2c97a..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);
@@ -545,6 +545,39 @@ 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 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);
+ }
+
+ [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()
{
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 @@
+
+