From 8f30e9c77a9e7a7b5c8ab445a85062777c821491 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:49:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Send=20Diagnostics=20=E2=80=94?= =?UTF-8?q?=20one-click=20redacted=20debug-bundle=20upload=20from=20the=20?= =?UTF-8?q?error=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New diagnostics.share_nous RPC reuses the CLI --nous pipeline (collect_share_bundle → build_nous_bundle → share_to_nous) with redaction forced on; accepts redacted error context + client-side extra files (local desktop.log on remote connections) with sanitized labels and size caps. Desktop: Send Diagnostics action on the failed-turn error card → consent modal (privacy notice, explicit Upload) → private view link + GitHub Issues / Nous Portal Support / Discord handoff. CLI --nous success output gets the same three-destination pointer. i18n en/ja/zh/zh-hant/ar; docs updated. --- apps/desktop/src/app/contrib/wiring.tsx | 5 + .../assistant-ui/thread/assistant-message.tsx | 11 +- .../components/send-diagnostics-dialog.tsx | 133 ++++++++++++++++ apps/desktop/src/i18n/ar.ts | 21 +++ apps/desktop/src/i18n/en.ts | 22 +++ apps/desktop/src/i18n/ja.ts | 22 +++ apps/desktop/src/i18n/types.ts | 21 +++ apps/desktop/src/i18n/zh-hant.ts | 22 +++ apps/desktop/src/i18n/zh.ts | 22 +++ .../src/store/send-diagnostics.test.ts | 144 ++++++++++++++++++ apps/desktop/src/store/send-diagnostics.ts | 119 +++++++++++++++ hermes_cli/debug.py | 6 + .../test_diagnostics_share_nous.py | 117 ++++++++++++++ tui_gateway/methods_config.py | 127 +++++++++++++-- website/docs/user-guide/desktop.md | 8 + 15 files changed, 789 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/send-diagnostics-dialog.tsx create mode 100644 apps/desktop/src/store/send-diagnostics.test.ts create mode 100644 apps/desktop/src/store/send-diagnostics.ts create mode 100644 tests/tui_gateway/test_diagnostics_share_nous.py diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 1b418400ea..67eb63c822 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -25,6 +25,7 @@ import { DesktopOnboardingOverlay } from '@/components/onboarding' import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store' import { FloatingPet } from '@/components/pet/floating-pet' import { RemoteDisplayBanner } from '@/components/remote-display-banner' +import { SendDiagnosticsHost } from '@/components/send-diagnostics-dialog' import { emitGatewayEvent } from '@/contrib/events' import { getLatestSessionMessages } from '@/hermes' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' @@ -1181,6 +1182,10 @@ export function ContribWiring({ children }: { children: ReactNode }) { {/* Backs confirm() from @/store/confirm — renders only while one is open. */} + {/* Send Diagnostics consent/upload dialog — driven by $sendDiagnostics + (error card action); renders nothing until requested. */} + + {/* Petdex floating mascot — renders nothing unless installed + enabled. Never in the HUD: that window is the chat bar and nothing else. */} {!isHudWindow() && } diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 84bc87c475..b604071d05 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -32,13 +32,14 @@ import { CopyButton } from '@/components/ui/copy-button' import { useI18n } from '@/i18n' import { type ErrorSurface, formatErrorDiagnostics } from '@/lib/error-surface' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons' +import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, Upload, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { markAssistantIdSpoken } from '@/lib/spoken-reply' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notifyError } from '@/store/notifications' +import { requestSendDiagnostics } from '@/store/send-diagnostics' import { $connection, $currentModel } from '@/store/session' import { $voicePlayback } from '@/store/voice-playback' @@ -554,6 +555,14 @@ const ErrorRecoveryActions: FC = () => { {remoteConnection ? copy.errorOpenDesktopLogs : copy.errorOpenLogs} )} + (!open && !busy ? dismissSendDiagnostics() : undefined)} open> + + {state.phase === 'consent' || state.phase === 'uploading' ? ( + <> + + + + {copy.title} + + + {copy.privacyNotice} + + + + + + + + ) : state.phase === 'error' ? ( + <> + + {copy.failedTitle} + + {state.error} + {'\n'} + {copy.failedHint} + + + + + + + ) : ( + <> + + {copy.doneTitle} + {copy.doneDescription} + + {state.result?.viewUrl && ( +
+ + {state.result.viewUrl} + + +
+ )} +
{copy.handoffLead}
+
+ {SUPPORT_LINKS.map(link => ( + + ))} +
+ + + + + )} +
+ + ) +} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index a30db33914..89fce6ff1a 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1,6 +1,26 @@ import { defineLocale } from './define-locale' export const ar = defineLocale({ + sendDiagnostics: { + title: 'إرسال التشخيصات إلى Nous', + privacyNotice: + 'سيؤدي هذا إلى رفع حزمة تصحيح إلى التخزين الداخلي لدى Nous (ليست لصيقة عامة). تتضمن معلومات النظام (نظام التشغيل، الإصدارات، المزوّد — وليس مفاتيح API الخاصة بك أبداً) وسجلات حديثة للوكيل والبوابة وسطح المكتب، وقد تحتوي على محتوى المحادثات ومسارات الملفات. تُحجب الأسرار قبل الرفع. لا يمكن الاطلاع عليها إلا لموظفي Nous، وتُحذف تلقائياً بعد 14 يوماً.', + upload: 'رفع', + uploading: 'جارٍ الرفع…', + cancel: 'إلغاء', + close: 'إغلاق', + copyLink: 'نسخ الرابط', + doneTitle: 'تم إرسال التشخيصات', + doneDescription: 'تم رفع الحزمة بشكل خاص. شارك الرابط أدناه في محادثة الدعم لكي يتمكن الفريق من رؤية سجلاتك.', + failedTitle: 'فشل الرفع', + failedHint: 'يمكنك أيضاً تشغيل `hermes debug share --nous` من الطرفية، أو `hermes debug share --local` لعرض التقرير دون رفعه.', + handoffLead: 'تابع النقاش في:', + links: { + github: 'GitHub Issues', + portal: 'دعم بوابة Nous', + discord: 'Discord' + } + }, common: { apply: 'تطبيق', back: 'رجوع', @@ -2457,6 +2477,7 @@ export const ar = defineLocale({ errorOpenLogsFailed: 'تعذّر فتح مجلد السجلات', errorOpenDesktopLogs: 'فتح سجلات سطح المكتب', errorCopyDiagnostics: 'نسخ تفاصيل الخطأ', + errorSendDiagnostics: 'إرسال التشخيصات', filesChanged: count => `${count} ملفات تم تغييرها`, reviewChanges: 'مراجعة', readAloudFailed: 'فشلت القراءة بصوت عال', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1adb019c81..5b2b93ac92 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -210,6 +210,27 @@ export const en: Translations = { dismiss: 'Dismiss' }, + sendDiagnostics: { + title: 'Send diagnostics to Nous', + privacyNotice: + 'This uploads a debug bundle to Nous-internal storage (not a public paste). It includes system info (OS, versions, provider — never your API keys) and recent agent, gateway, and desktop logs, which may contain conversation content and file paths. Secrets are redacted before upload. Only Nous staff can view it, and it auto-deletes after 14 days.', + upload: 'Upload', + uploading: 'Uploading…', + cancel: 'Cancel', + close: 'Close', + copyLink: 'Copy link', + doneTitle: 'Diagnostics sent', + doneDescription: 'Your bundle was uploaded privately. Share the link below in your support thread so the team can see your logs.', + failedTitle: 'Upload failed', + failedHint: 'You can also run `hermes debug share --nous` from a terminal, or `hermes debug share --local` to print the report without uploading.', + handoffLead: 'Pick up the discussion in:', + links: { + github: 'GitHub Issues', + portal: 'Nous Portal Support', + discord: 'Discord' + } + }, + titlebar: { hideSidebar: 'Hide sidebar', showSidebar: 'Show sidebar', @@ -3103,6 +3124,7 @@ export const en: Translations = { errorOpenLogsFailed: 'Could not open the logs folder', errorOpenDesktopLogs: 'Open Desktop logs', errorCopyDiagnostics: 'Copy error details', + errorSendDiagnostics: 'Send diagnostics', filesChanged: count => (count === 1 ? '1 file changed' : `${count} files changed`), reviewChanges: 'Review', readAloudFailed: 'Read aloud failed', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 5e522df6dd..4dc433fae2 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -211,6 +211,27 @@ export const ja = defineLocale({ dismiss: '閉じる' }, + sendDiagnostics: { + title: 'Nous に診断情報を送信', + privacyNotice: + 'デバッグバンドルを Nous 内部ストレージにアップロードします(公開ペーストではありません)。システム情報(OS、バージョン、プロバイダー — API キーは含まれません)と、最近のエージェント/ゲートウェイ/デスクトップのログ(会話内容やファイルパスを含む場合があります)が含まれます。シークレットはアップロード前にマスクされます。閲覧できるのは Nous スタッフのみで、14 日後に自動削除されます。', + upload: 'アップロード', + uploading: 'アップロード中…', + cancel: 'キャンセル', + close: '閉じる', + copyLink: 'リンクをコピー', + doneTitle: '診断情報を送信しました', + doneDescription: 'バンドルは非公開でアップロードされました。サポートスレッドで以下のリンクを共有すると、チームがログを確認できます。', + failedTitle: 'アップロードに失敗しました', + failedHint: 'ターミナルから `hermes debug share --nous` を実行するか、`hermes debug share --local` でアップロードせずにレポートを表示することもできます。', + handoffLead: '続きは次の場所で:', + links: { + github: 'GitHub Issues', + portal: 'Nous Portal サポート', + discord: 'Discord' + } + }, + titlebar: { hideSidebar: 'サイドバーを非表示', showSidebar: 'サイドバーを表示', @@ -2755,6 +2776,7 @@ export const ja = defineLocale({ errorOpenLogsFailed: 'ログフォルダを開けませんでした', errorOpenDesktopLogs: 'デスクトップのログを開く', errorCopyDiagnostics: 'エラー詳細をコピー', + errorSendDiagnostics: '診断情報を送信', filesChanged: count => `${count} 件のファイルを変更`, reviewChanges: 'レビュー', readAloudFailed: '読み上げに失敗しました', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4440e5134f..0bd5967ca8 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -250,6 +250,26 @@ export interface Translations { dismiss: string } + sendDiagnostics: { + title: string + privacyNotice: string + upload: string + uploading: string + cancel: string + close: string + copyLink: string + doneTitle: string + doneDescription: string + failedTitle: string + failedHint: string + handoffLead: string + links: { + discord: string + github: string + portal: string + } + } + titlebar: { hideSidebar: string showSidebar: string @@ -2672,6 +2692,7 @@ export interface Translations { errorOpenLogsFailed: string errorOpenDesktopLogs: string errorCopyDiagnostics: string + errorSendDiagnostics: string filesChanged: (count: number) => string reviewChanges: string readAloudFailed: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 593ab6bdb4..7878988cf4 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -204,6 +204,27 @@ export const zhHant = defineLocale({ dismiss: '忽略' }, + sendDiagnostics: { + title: '向 Nous 傳送診斷資訊', + privacyNotice: + '這會將偵錯套件上傳到 Nous 內部儲存空間(並非公開貼上板)。內容包括系統資訊(作業系統、版本、服務商 — 絕不包含您的 API 金鑰)以及最近的 agent、gateway 與桌面端日誌(可能包含對話內容與檔案路徑)。上傳前會先遮罩機密資訊。僅 Nous 員工可檢視,14 天後自動刪除。', + upload: '上傳', + uploading: '上傳中…', + cancel: '取消', + close: '關閉', + copyLink: '複製連結', + doneTitle: '診斷資訊已傳送', + doneDescription: '偵錯套件已私密上傳。在您的支援討論串中分享以下連結,團隊即可檢視您的日誌。', + failedTitle: '上傳失敗', + failedHint: '您也可以在終端機執行 `hermes debug share --nous`,或執行 `hermes debug share --local` 在不上傳的情況下檢視報告。', + handoffLead: '在以下位置繼續討論:', + links: { + github: 'GitHub Issues', + portal: 'Nous Portal 支援', + discord: 'Discord' + } + }, + titlebar: { hideSidebar: '隱藏側邊欄', showSidebar: '顯示側邊欄', @@ -2664,6 +2685,7 @@ export const zhHant = defineLocale({ errorOpenLogsFailed: '無法開啟日誌資料夾', errorOpenDesktopLogs: '開啟桌面端日誌', errorCopyDiagnostics: '複製錯誤詳細資訊', + errorSendDiagnostics: '傳送診斷資訊', filesChanged: count => `${count} 個檔案已變更`, reviewChanges: '檢視', readAloudFailed: '朗讀失敗', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 4fa78ec6a0..b9286a06c6 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -204,6 +204,27 @@ export const zh: Translations = { dismiss: '忽略' }, + sendDiagnostics: { + title: '向 Nous 发送诊断信息', + privacyNotice: + '这会将调试包上传到 Nous 内部存储(并非公开粘贴板)。内容包括系统信息(操作系统、版本、服务商 — 绝不包含您的 API 密钥)以及最近的 agent、gateway 和桌面端日志(可能包含对话内容与文件路径)。上传前会先脱敏。仅 Nous 员工可查看,14 天后自动删除。', + upload: '上传', + uploading: '上传中…', + cancel: '取消', + close: '关闭', + copyLink: '复制链接', + doneTitle: '诊断信息已发送', + doneDescription: '调试包已私密上传。在您的支持会话中分享以下链接,团队即可查看您的日志。', + failedTitle: '上传失败', + failedHint: '您也可以在终端运行 `hermes debug share --nous`,或运行 `hermes debug share --local` 在不上传的情况下查看报告。', + handoffLead: '在以下位置继续讨论:', + links: { + github: 'GitHub Issues', + portal: 'Nous Portal 支持', + discord: 'Discord' + } + }, + titlebar: { hideSidebar: '隐藏侧边栏', showSidebar: '显示侧边栏', @@ -3266,6 +3287,7 @@ export const zh: Translations = { errorOpenLogsFailed: '无法打开日志文件夹', errorOpenDesktopLogs: '打开桌面端日志', errorCopyDiagnostics: '复制错误详情', + errorSendDiagnostics: '发送诊断信息', filesChanged: count => `${count} 个文件已更改`, reviewChanges: '查看', readAloudFailed: '朗读失败', diff --git a/apps/desktop/src/store/send-diagnostics.test.ts b/apps/desktop/src/store/send-diagnostics.test.ts new file mode 100644 index 0000000000..b69e1951f9 --- /dev/null +++ b/apps/desktop/src/store/send-diagnostics.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $gateway } from '@/store/gateway' +import { + $sendDiagnostics, + confirmSendDiagnostics, + dismissSendDiagnostics, + requestSendDiagnostics +} from '@/store/send-diagnostics' + +function stubGateway(request: (method: string, params?: Record, timeout?: number) => Promise) { + const original = $gateway.get() + + $gateway.set({ request } as never) + + return () => $gateway.set(original) +} + +function stubDesktopLogs(lines: null | string[]) { + const original = window.hermesDesktop + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: lines ? { getRecentLogs: async () => ({ lines, path: '/tmp/desktop.log' }) } : undefined + }) + + return () => Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: original }) +} + +describe('send-diagnostics store', () => { + afterEach(() => { + $sendDiagnostics.set(null) + vi.restoreAllMocks() + }) + + it('opens in consent phase without any network I/O', () => { + const request = vi.fn() + const restore = stubGateway(request) + + try { + requestSendDiagnostics('layer: provider') + + expect($sendDiagnostics.get()).toEqual({ errorContext: 'layer: provider', phase: 'consent' }) + expect(request).not.toHaveBeenCalled() + } finally { + restore() + } + }) + + it('uploads on confirm, attaching error context and the local desktop log', async () => { + const request = vi.fn().mockResolvedValue({ + ok: true, + view_url: 'https://nas.example/view/x1', + upload_id: 'x1', + expires_at: '2026-09-05T00:00:00Z' + }) + + const restoreGateway = stubGateway(request) + const restoreDesktop = stubDesktopLogs(['boot ok', 'ws connected']) + + try { + requestSendDiagnostics('layer: streaming\ncode: stream_drop') + await confirmSendDiagnostics() + + expect(request).toHaveBeenCalledTimes(1) + const [method, params] = request.mock.calls[0] + + expect(method).toBe('diagnostics.share_nous') + expect(params.error_context).toContain('stream_drop') + expect(params.extra_files['desktop.log']).toContain('ws connected') + + const state = $sendDiagnostics.get() + + expect(state?.phase).toBe('done') + expect(state?.result?.viewUrl).toBe('https://nas.example/view/x1') + } finally { + restoreDesktop() + restoreGateway() + } + }) + + it('omits extra_files when the desktop IPC is unavailable (browser dashboard)', async () => { + const request = vi.fn().mockResolvedValue({ ok: true, view_url: 'https://nas.example/view/x2' }) + const restoreGateway = stubGateway(request) + const restoreDesktop = stubDesktopLogs(null) + + try { + requestSendDiagnostics() + await confirmSendDiagnostics() + + const [, params] = request.mock.calls[0] + + expect(params.extra_files).toBeUndefined() + expect(params.error_context).toBeUndefined() + expect($sendDiagnostics.get()?.phase).toBe('done') + } finally { + restoreDesktop() + restoreGateway() + } + }) + + it('surfaces upload failures inline and keeps the dialog open', async () => { + const request = vi.fn().mockResolvedValue({ ok: false, error: 'NAS unavailable' }) + const restoreGateway = stubGateway(request) + const restoreDesktop = stubDesktopLogs(null) + + try { + requestSendDiagnostics() + await confirmSendDiagnostics() + + const state = $sendDiagnostics.get() + + expect(state?.phase).toBe('error') + expect(state?.error).toContain('NAS unavailable') + } finally { + restoreDesktop() + restoreGateway() + } + }) + + it('confirm is a no-op outside the consent phase (no double upload)', async () => { + const request = vi.fn().mockResolvedValue({ ok: true }) + const restoreGateway = stubGateway(request) + const restoreDesktop = stubDesktopLogs(null) + + try { + requestSendDiagnostics() + await confirmSendDiagnostics() + await confirmSendDiagnostics() + + expect(request).toHaveBeenCalledTimes(1) + } finally { + restoreDesktop() + restoreGateway() + } + }) + + it('dismiss clears the dialog state', () => { + requestSendDiagnostics() + dismissSendDiagnostics() + + expect($sendDiagnostics.get()).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/send-diagnostics.ts b/apps/desktop/src/store/send-diagnostics.ts new file mode 100644 index 0000000000..6cbe9589b3 --- /dev/null +++ b/apps/desktop/src/store/send-diagnostics.ts @@ -0,0 +1,119 @@ +// "Send Diagnostics" — the error card's consent-gated debug-bundle upload. +// +// Flow: an error card (or any surface) calls requestSendDiagnostics() with +// optional error context → the modal host renders the privacy notice → the +// user explicitly clicks Upload → diagnostics.share_nous runs backend-side +// (collect + force-redact + Nous-S3 upload) → the modal shows the private +// view link plus the support handoff (GitHub Issues · Nous Portal Support · +// Discord). +// +// Consent is per-upload and explicit — no "always allow", mirroring the CLI's +// `hermes debug share --nous` confirmation contract. On a remote connection +// the backend bundles ITS OWN logs (the runtime that owns the failure); the +// local desktop.log is attached as a client-side extra so support sees both +// halves in one bundle. +import { atom } from 'nanostores' + +import { $gateway } from '@/store/gateway' + +export interface SendDiagnosticsResult { + expiresAt?: string + uploadId?: string + viewUrl?: string +} + +export interface SendDiagnosticsState { + /** Short text describing the failure that prompted the report (attached + * to the bundle as error-context.txt, redacted server-side). */ + errorContext?: string + error?: string + phase: 'consent' | 'done' | 'error' | 'uploading' + result?: SendDiagnosticsResult +} + +export const $sendDiagnostics = atom(null) + +/** Open the consent modal. No network I/O happens until the user confirms. */ +export function requestSendDiagnostics(errorContext?: string): void { + $sendDiagnostics.set({ errorContext, phase: 'consent' }) +} + +export function dismissSendDiagnostics(): void { + $sendDiagnostics.set(null) +} + +interface ShareNousResponse { + error?: string + expires_at?: string + ok: boolean + upload_id?: string + view_url?: string +} + +/** Read the LOCAL desktop log via Electron so a remote backend's bundle still + * carries the Desktop-side transport evidence. Best-effort: absence of the + * IPC (browser dashboard, older shells) just omits the file. */ +async function collectLocalExtras(): Promise> { + try { + const logs = await window.hermesDesktop?.getRecentLogs?.() + const lines = Array.isArray(logs?.lines) ? logs.lines : [] + + return lines.length ? { 'desktop.log': lines.join('\n') } : {} + } catch { + return {} + } +} + +// Bundle collection + upload legitimately takes a while (log reads + gzip + +// S3 leg); the default WS timeout is too tight for slow disks/links. +const SHARE_TIMEOUT_MS = 120_000 + +/** User confirmed — run the upload. Transitions consent → uploading → done/error. */ +export async function confirmSendDiagnostics(): Promise { + const current = $sendDiagnostics.get() + + if (!current || current.phase !== 'consent') { + return + } + + $sendDiagnostics.set({ ...current, phase: 'uploading' }) + + try { + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + const extraFiles = await collectLocalExtras() + + const response = await gateway.request( + 'diagnostics.share_nous', + { + ...(current.errorContext ? { error_context: current.errorContext } : {}), + ...(Object.keys(extraFiles).length ? { extra_files: extraFiles } : {}) + }, + SHARE_TIMEOUT_MS + ) + + if (!response.ok) { + throw new Error(response.error || 'upload failed') + } + + $sendDiagnostics.set({ + ...current, + phase: 'done', + result: { + expiresAt: response.expires_at, + uploadId: response.upload_id, + viewUrl: response.view_url + } + }) + } catch (error) { + $sendDiagnostics.set({ + ...current, + error: error instanceof Error ? error.message : String(error), + phase: 'error' + }) + } +} diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 0404e78371..f633df62f2 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -986,6 +986,12 @@ def _run_debug_share_nous(args, *, log_lines: int, redact: bool) -> None: "\nShare this private link with the Nous team — only Nous staff " "(via Google login) can open it." ) + print( + "\nPick up the discussion in:\n" + " GitHub Issues https://github.com/NousResearch/hermes-agent/issues\n" + " Nous Portal Support https://portal.nousresearch.com/help\n" + " Discord https://discord.gg/NousResearch" + ) def run_debug_delete(args): diff --git a/tests/tui_gateway/test_diagnostics_share_nous.py b/tests/tui_gateway/test_diagnostics_share_nous.py new file mode 100644 index 0000000000..b01608df51 --- /dev/null +++ b/tests/tui_gateway/test_diagnostics_share_nous.py @@ -0,0 +1,117 @@ +"""diagnostics.share_nous RPC — Desktop "Send Diagnostics" upload path. + +Contract pinned: +* Reuses the CLI ``--nous`` pipeline (collect_share_bundle → build_nous_bundle + → share_to_nous) with redaction FORCED on — the client cannot disable it. +* ``error_context`` and ``extra_files`` are redacted server-side, labels + sanitized, sizes capped. +* Upload failures return a structured ``{ok: False, error}`` envelope, never a + JSON-RPC error (the desktop renders them inline in the modal). +""" + +from __future__ import annotations + +import gzip +import json + +import pytest + +from tui_gateway import server + + +def _handler(): + fn = server._methods.get("diagnostics.share_nous") + assert fn is not None, "diagnostics.share_nous not registered" + return fn + + +@pytest.fixture() +def captured_upload(monkeypatch, tmp_path): + """Mock ONLY the network leg; the bundle pipeline runs for real.""" + captured: dict = {} + + def _fake_share(blob: bytes) -> dict: + captured["blob"] = blob + return { + "viewUrl": "https://nas.example/view/abc123", + "id": "abc123", + "expiresAt": "2026-09-05T00:00:00Z", + } + + import hermes_cli.diagnostics_upload as du + + monkeypatch.setattr(du, "share_to_nous", _fake_share) + return captured + + +def _envelope(blob: bytes) -> dict: + return json.loads(gzip.decompress(blob).decode("utf-8")) + + +def test_share_nous_uploads_redacted_bundle(captured_upload): + result = _handler()("rid-1", {}) + payload = result["result"] + + assert payload["ok"] is True + assert payload["view_url"] == "https://nas.example/view/abc123" + assert payload["upload_id"] == "abc123" + + envelope = _envelope(captured_upload["blob"]) + assert envelope["format"].startswith("hermes-debug-share/") + assert envelope["redacted"] is True + assert "report" in envelope["files"] + + +def test_share_nous_attaches_redacted_error_context(captured_upload): + secret = "sk-abc123def456ghi789jkl012mno345pqr678" + result = _handler()( + "rid-2", + {"error_context": f"layer: provider\ncode: rate_limit\nkey was {secret}"}, + ) + assert result["result"]["ok"] is True + + files = _envelope(captured_upload["blob"])["files"] + context = files.get("error-context.txt", "") + assert "layer: provider" in context + assert secret not in context, "secret leaked through error_context redaction" + + +def test_share_nous_extra_files_sanitized_and_redacted(captured_upload): + secret = "sk-abc123def456ghi789jkl012mno345pqr678" + result = _handler()( + "rid-3", + { + "extra_files": { + "desktop.log": f"boot ok\ntoken={secret}\n", + "../../etc/passwd": "nope", + "ok name (1).txt": "fine", + 7: "not-a-str-label", + "empty": " ", + } + }, + ) + assert result["result"]["ok"] is True + + files = _envelope(captured_upload["blob"])["files"] + assert "client/desktop.log" in files + assert secret not in files["client/desktop.log"] + # Path separators are stripped from labels; traversal shapes can't survive. + assert not any("/etc/passwd" in k or ".." in k for k in files) + assert "client/ok name (1).txt" in files + # Non-string labels and blank bodies are dropped. + assert not any(k.startswith("client/7") for k in files) + assert "client/empty" not in files + + +def test_share_nous_upload_failure_is_structured(monkeypatch): + import hermes_cli.diagnostics_upload as du + + def _boom(blob: bytes) -> dict: + raise RuntimeError("NAS unavailable") + + monkeypatch.setattr(du, "share_to_nous", _boom) + + result = _handler()("rid-4", {}) + payload = result["result"] + assert payload["ok"] is False + assert "NAS unavailable" in payload["error"] diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 314b38d904..d92e5e38f0 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -125,7 +125,9 @@ def _(rid, params: dict) -> dict: try: with _profile_db(params) as db: if db is None: - return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []}) + return _ok( + rid, {"projects": [], "active_id": None, "scoped_session_ids": []} + ) tree, active_id = _build_project_tree( db, @@ -136,7 +138,11 @@ def _(rid, params: dict) -> dict: ) return _ok( rid, - {"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]}, + { + "projects": tree["projects"], + "active_id": active_id, + "scoped_session_ids": tree["scoped_session_ids"], + }, ) except Exception as e: return _err(rid, 5061, str(e)) @@ -160,7 +166,10 @@ def _(rid, params: dict) -> dict: # Drill-in only needs the entered project (which has sessions), so skip # the zero-session discovery tier entirely. tree, _active = _build_project_tree( - db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000), + db, + preview_limit=0, + hydrate=True, + session_limit=int(params.get("session_limit") or 5000), include_discovered=False, ) proj = next((p for p in tree["projects"] if p["id"] == project_id), None) @@ -320,7 +329,15 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"value": "on" if on else "off"}) if key == "theme": display = _load_cfg().get("display") - raw = str(display.get("tui_theme", "auto") if isinstance(display, dict) else "auto").strip().lower() + raw = ( + str( + display.get("tui_theme", "auto") + if isinstance(display, dict) + else "auto" + ) + .strip() + .lower() + ) return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"}) if key == "statusbar": display = _load_cfg().get("display") @@ -330,10 +347,17 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"value": _coerce_statusbar(raw)}) if key == "focus": display = _load_cfg().get("display") - on = bool(display.get("focus_view", False)) if isinstance(display, dict) else False + on = ( + bool(display.get("focus_view", False)) + if isinstance(display, dict) + else False + ) return _ok( rid, - {"value": "on" if on else "off", "tool_progress": _load_tool_progress_mode()}, + { + "value": "on" if on else "off", + "tool_progress": _load_tool_progress_mode(), + }, ) if key == "mouse": display = _load_cfg().get("display") @@ -383,10 +407,15 @@ def _(rid, params: dict) -> dict: provider_configured = bool(_has_any_provider_configured()) provider = runtime.get("provider") or "provider" source = str(runtime.get("source") or "") - if not provider_configured and provider == "bedrock" and source in { - "iam-role", - "aws-sdk-default-chain", - }: + if ( + not provider_configured + and provider == "bedrock" + and source + in { + "iam-role", + "aws-sdk-default-chain", + } + ): return _ok( rid, { @@ -432,6 +461,84 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": False, "error": str(e)}) +@method("diagnostics.share_nous") +def _(rid, params: dict) -> dict: + """Upload a redacted debug bundle to Nous-internal diagnostics storage. + + Desktop's "Send Diagnostics" action (error card / diagnostics UI). Same + collection + force-redaction pipeline as ``hermes debug share --nous`` + (collect_share_bundle → build_nous_bundle → share_to_nous); redaction is + NOT client-controllable — this handler always redacts. + + Params (all optional): + - ``error_context``: short client-supplied text describing the failure + that prompted the report (the error card's layer/code/message blob). + Redacted server-side and attached as ``error-context.txt``. + - ``extra_files``: {label → text} of client-side artifacts the backend + can't see (e.g. the local desktop.log when this backend is remote). + Each value is force-redacted server-side before inclusion; labels are + sanitized and size-capped. + - ``log_lines``: report excerpt length (default 200). + + Consent lives with the CALLER: the desktop shows the privacy notice and + an explicit Upload button before invoking this. Structured envelope + (``ok``/``error``) rather than JSON-RPC errors so the client can render + upload failures inline. + """ + try: + from agent.redact import redact_sensitive_text + from hermes_cli.debug import build_nous_bundle, collect_share_bundle + from hermes_cli.diagnostics_upload import share_to_nous + + log_lines = params.get("log_lines") + if not isinstance(log_lines, int) or not (10 <= log_lines <= 2000): + log_lines = 200 + + bundle = collect_share_bundle(log_lines=log_lines, redact=True) + + error_context = params.get("error_context") + if isinstance(error_context, str) and error_context.strip(): + bundle["error-context.txt"] = redact_sensitive_text( + error_context.strip()[:8_000], force=True + ) + + # Client-side artifacts (local desktop.log on remote connections). + # Bounded: at most 4 files, 512KB of text each, sanitized labels — + # this is a diagnostics channel, not an arbitrary upload surface. + extra_files = params.get("extra_files") + if isinstance(extra_files, dict): + for label, text in list(extra_files.items())[:4]: + if not isinstance(label, str) or not isinstance(text, str): + continue + safe_label = "".join( + ch for ch in label if ch.isalnum() or ch in "._- ()" + ).strip()[:64] + # Collapse dot-runs and leading dots so traversal-shaped labels + # ("../../etc/passwd") can't survive even cosmetically. + while ".." in safe_label: + safe_label = safe_label.replace("..", ".") + safe_label = safe_label.lstrip(".").strip() + if not safe_label or not text.strip(): + continue + bundle[f"client/{safe_label}"] = redact_sensitive_text( + text[:524_288], force=True + ) + + blob = build_nous_bundle(bundle, redact=True) + res = share_to_nous(blob) + return _ok( + rid, + { + "ok": True, + "view_url": res.get("viewUrl") or res.get("view_url"), + "upload_id": res.get("id"), + "expires_at": res.get("expiresAt") or res.get("expires_at"), + }, + ) + except Exception as e: + return _ok(rid, {"ok": False, "error": str(e)}) + + def register(server) -> None: """Bind this module's handlers onto ``server``'s globals and registry.""" _registry.install(server) diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index c6d7439a6a..0a4cf668a0 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -424,6 +424,14 @@ generic error toast. The card offers recovery actions matched to the failure: or Cloud connection the button reads **Open Desktop logs**: it opens the local Desktop-side logs (transport evidence), since the failed turn's gateway/agent logs live on the remote machine. +- **Send diagnostics** — uploads a redacted debug bundle to Nous-internal + storage after an explicit consent prompt (same pipeline as + `hermes debug share --nous`; secrets are always redacted, the bundle is + viewable by Nous staff only and auto-deletes after 14 days). On success you + get a private view link to paste into your support thread, plus quick links + to GitHub Issues, Nous Portal Support, and Discord. On a remote or Cloud + connection the backend bundles its own agent/gateway logs and the local + Desktop log is attached alongside, so support sees both halves. - **Copy error details** — copies a compact plain-text summary (layer, code, provider/model, error message) you can paste into a bug report or Discord.