diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index f11ff4d2d30..6ffefe53600 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -7,6 +7,7 @@ import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/compone import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { previewBridge } from "./previewBridge"; +import { reportPreviewActionFailure } from "./reportPreviewActionFailure"; interface Props { /** Active preview tab id. Tab-targeting actions are disabled without it. */ @@ -30,9 +31,11 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { if (!previewBridge) return null; const bridge = previewBridge; const tabDisabled = !tabId || !hasWebContents; - const callTab = (op: (tabId: string) => Promise) => () => { + const callTab = (operation: string, op: (tabId: string) => Promise) => () => { if (!tabId) return; - void op(tabId).catch(() => undefined); + void op(tabId).catch((cause) => { + reportPreviewActionFailure({ operation, tabId }, cause); + }); }; const zoomLabel = `${Math.round(zoomFactor * 100)}%`; @@ -53,10 +56,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { More - + Hard reload - + Open DevTools {/* @@ -75,7 +78,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { variant="outline" size="icon-xs" type="button" - onClick={callTab(bridge.zoomOut)} + onClick={callTab("zoom-out", bridge.zoomOut)} aria-label="Zoom out" disabled={tabDisabled} > @@ -88,7 +91,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { variant="outline" size="icon-xs" type="button" - onClick={callTab(bridge.zoomIn)} + onClick={callTab("zoom-in", bridge.zoomIn)} aria-label="Zoom in" disabled={tabDisabled} > @@ -98,7 +101,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { variant="ghost" size="icon-xs" type="button" - onClick={callTab(bridge.resetZoom)} + onClick={callTab("reset-zoom", bridge.resetZoom)} aria-label="Reset zoom" disabled={tabDisabled} > @@ -107,10 +110,22 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { - void bridge.clearCookies().catch(() => undefined)}> + + void bridge.clearCookies().catch((cause) => { + reportPreviewActionFailure({ operation: "clear-cookies" }, cause); + }) + } + > Clear cookies - void bridge.clearCache().catch(() => undefined)}> + + void bridge.clearCache().catch((cause) => { + reportPreviewActionFailure({ operation: "clear-cache" }, cause); + }) + } + > Clear cache diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 861a8df616b..038859f881a 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,6 +1,7 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -20,6 +21,7 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { previewUrlFailureContext, reportPreviewActionFailure } from "./reportPreviewActionFailure"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -91,58 +93,112 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, environmentHttpBaseUrl, }) ?? undefined) : undefined; + const threadKey = scopedThreadKey(threadRef); const handleSubmitUrl = useCallback( async (next: string) => { + let operation = "resolve-url"; + let targetUrl = next; try { const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); + targetUrl = resolvedUrl; if (tabId && previewBridge) { // Drive the webview imperatively; `usePreviewBridge` mirrors the // resolved URL back to the server so other clients stay in sync. + operation = "navigate"; await previewBridge.navigate(tabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); } else { - await openPreviewSession({ + operation = "open-session"; + const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl, }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportPreviewActionFailure( + { + operation, + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(targetUrl), + }, + result.cause, + ); + } } - } catch { + } catch (cause) { + reportPreviewActionFailure( + { + operation, + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(targetUrl), + }, + cause, + ); // Server-side `failed` event renders the unreachable view. } }, - [open, tabId, threadRef], + [open, tabId, threadKey, threadRef], ); const handleRefresh = useCallback(() => { - if (previewBridge && tabId) void previewBridge.refresh(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.refresh(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "refresh", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleZoomIn = useCallback(() => { - if (previewBridge && tabId) void previewBridge.zoomIn(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.zoomIn(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "zoom-in", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleZoomOut = useCallback(() => { - if (previewBridge && tabId) void previewBridge.zoomOut(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.zoomOut(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "zoom-out", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleResetZoom = useCallback(() => { - if (previewBridge && tabId) void previewBridge.resetZoom(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.resetZoom(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "reset-zoom", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleBack = useCallback(() => { - if (previewBridge && tabId) void previewBridge.goBack(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.goBack(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "go-back", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleForward = useCallback(() => { - if (previewBridge && tabId) void previewBridge.goForward(tabId); - }, [tabId]); + if (!previewBridge || !tabId) return; + void previewBridge.goForward(tabId).catch((cause) => { + reportPreviewActionFailure({ operation: "go-forward", threadKey, tabId }, cause); + }); + }, [tabId, threadKey]); const handleOpenInBrowser = useCallback(() => { if (!localApi || !url) return; - void localApi.shell.openExternal(url).catch(() => undefined); - }, [url]); + void localApi.shell.openExternal(url).catch((cause) => { + reportPreviewActionFailure( + { + operation: "open-external", + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(url), + }, + cause, + ); + }); + }, [tabId, threadKey, url]); const handleCapture = useCallback( (record: boolean) => { @@ -180,6 +236,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, 2_000); }, (error) => { + reportPreviewActionFailure( + { + operation: "copy-recording-path", + threadKey, + tabId, + artifactPath: artifact.path, + }, + error, + ); toastManager.update( toastId, stackedThreadToast({ @@ -195,7 +260,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const revealAction = { children: revealInFileExplorerLabel(navigator.platform), - onClick: () => void bridge.revealArtifact(artifact.path), + onClick: () => + void bridge.revealArtifact(artifact.path).catch((cause) => { + reportPreviewActionFailure( + { + operation: "reveal-recording", + threadKey, + tabId, + artifactPath: artifact.path, + }, + cause, + ); + }), }; const updateRecordingToast = () => { toastManager.update( @@ -232,6 +308,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); }, (error) => { + reportPreviewActionFailure({ operation: "stop-recording", threadKey, tabId }, error); toastManager.add({ type: "error", title: "Unable to stop recording", @@ -251,6 +328,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, return; } void startBrowserRecording(tabId).catch((error) => { + reportPreviewActionFailure({ operation: "start-recording", threadKey, tabId }, error); toastManager.add({ type: "error", title: "Unable to start recording", @@ -263,7 +341,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, (artifact) => { const revealAction = { children: revealInFileExplorerLabel(navigator.platform), - onClick: () => void bridge.revealArtifact(artifact.path), + onClick: () => + void bridge.revealArtifact(artifact.path).catch((cause) => { + reportPreviewActionFailure( + { + operation: "reveal-screenshot", + threadKey, + tabId, + artifactPath: artifact.path, + }, + cause, + ); + }), }; let pathCopied = false; let imageCopied = false; @@ -325,6 +414,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, 2_000); }, (error) => { + reportPreviewActionFailure( + { + operation: "copy-screenshot-path", + threadKey, + tabId, + artifactPath: artifact.path, + }, + error, + ); updateScreenshotToast( "error", "Unable to copy screenshot path", @@ -345,6 +443,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, 2_000); }, (error) => { + reportPreviewActionFailure( + { + operation: "copy-screenshot-image", + threadKey, + tabId, + artifactPath: artifact.path, + }, + error, + ); updateScreenshotToast( "error", "Unable to copy screenshot", @@ -381,6 +488,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); }, (error) => { + reportPreviewActionFailure({ operation: "capture-screenshot", threadKey, tabId }, error); toastManager.add({ type: "error", title: "Unable to capture screenshot", @@ -389,13 +497,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, ); }, - [activeRecordingTabId, tabId], + [activeRecordingTabId, tabId, threadKey], ); const handlePickElement = useCallback(() => { if (!previewBridge || !tabId) return; if (pickActiveRef.current) { - void previewBridge.cancelPickElement(tabId).catch(() => undefined); + void previewBridge.cancelPickElement(tabId).catch((cause) => { + reportPreviewActionFailure( + { operation: "cancel-element-picker", threadKey, tabId, trigger: "toggle" }, + cause, + ); + }); return; } // Snapshot whatever the user was focused on (typically the chat @@ -408,12 +521,18 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, pickActiveRef.current = true; setPickActive(true); void (async () => { + let operation = "pick-element"; + let annotationId: string | undefined; try { const annotation = await previewBridge.pickElement(tabId); if (!annotation) return; + annotationId = annotation.id; + operation = "add-preview-annotation"; addPreviewAnnotation(threadRef, annotation); + operation = "prepare-annotation-screenshot"; const screenshotFile = await previewAnnotationScreenshotFile(annotation); if (screenshotFile && annotation.screenshot) { + operation = "attach-annotation-screenshot"; addImage(threadRef, { type: "image", id: annotation.id, @@ -424,8 +543,17 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, file: screenshotFile, }); } - } catch { - // Picker failed (e.g. webview navigated). Treat as silent cancel. + } catch (cause) { + reportPreviewActionFailure( + { + operation, + threadKey, + tabId, + ...(annotationId ? { annotationId } : {}), + }, + cause, + ); + // Keep picker failures silent in the UI; navigating during a pick is expected. } finally { pickActiveRef.current = false; // Avoid `setState on unmounted component` if the panel/thread closed @@ -447,7 +575,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, } } })(); - }, [addImage, addPreviewAnnotation, tabId, threadRef]); + }, [addImage, addPreviewAnnotation, tabId, threadKey, threadRef]); // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle @@ -457,11 +585,16 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, if (!pickActiveRef.current) return; pickActiveRef.current = false; if (previewBridge && tabId) { - void previewBridge.cancelPickElement(tabId).catch(() => undefined); + void previewBridge.cancelPickElement(tabId).catch((cause) => { + reportPreviewActionFailure( + { operation: "cancel-element-picker", threadKey, tabId, trigger: "tab-change" }, + cause, + ); + }); } if (isMountedRef.current) setPickActive(false); }; - }, [tabId]); + }, [tabId, threadKey]); // Subscribe only while visible; `toggle-panel` is owned by ChatView's // URL-aware handler regardless of whether the panel is currently mounted. @@ -491,10 +624,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); return ( -
+
{ + it("logs safe preview target metadata without credentials or URL parameters", () => { + const url = + "https://user:password@example.com/preview/signed-secret-token?access_token=private#fragment"; + const cause = new Error("navigation failed"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + reportPreviewActionFailure( + { + operation: "navigate", + ...previewUrlFailureContext(url), + }, + cause, + ); + + expect(consoleError).toHaveBeenCalledWith( + "[preview] action failed", + { + operation: "navigate", + urlHostname: "example.com", + urlLength: url.length, + urlProtocol: "https:", + }, + cause, + ); + const loggedContext = consoleError.mock.calls[0]?.[1]; + expect(loggedContext).not.toHaveProperty("url"); + expect(JSON.stringify(loggedContext)).not.toContain("signed-secret-token"); + expect(JSON.stringify(loggedContext)).not.toContain("access_token=private"); + + consoleError.mockRestore(); + }); +}); diff --git a/apps/web/src/components/preview/reportPreviewActionFailure.ts b/apps/web/src/components/preview/reportPreviewActionFailure.ts new file mode 100644 index 00000000000..ceaac4cd1a9 --- /dev/null +++ b/apps/web/src/components/preview/reportPreviewActionFailure.ts @@ -0,0 +1,29 @@ +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; + +export interface PreviewActionFailureContext { + readonly operation: string; + readonly threadKey?: string; + readonly tabId?: string; + readonly urlHostname?: string; + readonly urlLength?: number; + readonly urlProtocol?: string; + readonly artifactPath?: string; + readonly annotationId?: string; + readonly trigger?: string; +} + +export function previewUrlFailureContext(url: string) { + const diagnostics = getUrlDiagnostics(url); + return { + urlLength: diagnostics.inputLength, + ...(diagnostics.hostname === undefined ? {} : { urlHostname: diagnostics.hostname }), + ...(diagnostics.protocol === undefined ? {} : { urlProtocol: diagnostics.protocol }), + }; +} + +export function reportPreviewActionFailure( + context: PreviewActionFailureContext, + cause: unknown, +): void { + console.error("[preview] action failed", context, cause); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 23705178bef..497a7e84f97 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,6 +103,10 @@ "types": "./src/dpopCommon.ts", "import": "./src/dpopCommon.ts" }, + "./urlDiagnostics": { + "types": "./src/urlDiagnostics.ts", + "import": "./src/urlDiagnostics.ts" + }, "./relayAuth": { "types": "./src/relayAuth.ts", "import": "./src/relayAuth.ts" diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts index c4ba298f66c..767a6ecf267 100644 --- a/packages/shared/src/dpop.test.ts +++ b/packages/shared/src/dpop.test.ts @@ -6,6 +6,7 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, normalizeDpopHtu, + redactDpopRequestTarget, type DpopPublicJwk, verifyDpopProof, } from "./dpop.ts"; @@ -41,6 +42,18 @@ function signDpopProof(input: { return `${header}.${payload}.${signature}`; } +describe("redactDpopRequestTarget", () => { + it("retains the scheme, host, port, and path while removing sensitive URL components", () => { + const url = "https://user:password@example.com:8443/oauth/token?code=secret#fragment"; + + assert.equal(redactDpopRequestTarget(url), "https://example.com:8443/oauth/token"); + }); + + it("returns a safe sentinel for invalid input", () => { + assert.equal(redactDpopRequestTarget("not a URL?token=secret"), ""); + }); +}); + describe("verifyDpopProof", () => { const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256", diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 88dcf8e3090..f2dd17516d0 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -88,6 +88,15 @@ export function normalizeDpopHtu(url: string): string | null { } } +export function redactDpopRequestTarget(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + } catch { + return ""; + } +} + export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); } diff --git a/packages/shared/src/urlDiagnostics.test.ts b/packages/shared/src/urlDiagnostics.test.ts new file mode 100644 index 00000000000..b3ff378ee55 --- /dev/null +++ b/packages/shared/src/urlDiagnostics.test.ts @@ -0,0 +1,24 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { getUrlDiagnostics } from "./urlDiagnostics.ts"; + +describe("getUrlDiagnostics", () => { + it("retains only input length, protocol, and hostname for valid URLs", () => { + const input = + "https://user:password@example.com:8443/private/path?access_token=secret#fragment"; + + assert.deepStrictEqual(getUrlDiagnostics(input), { + inputLength: input.length, + protocol: "https:", + hostname: "example.com", + }); + }); + + it("returns only input length for invalid URLs", () => { + const input = "not a URL?access_token=secret"; + + assert.deepStrictEqual(getUrlDiagnostics(input), { + inputLength: input.length, + }); + }); +}); diff --git a/packages/shared/src/urlDiagnostics.ts b/packages/shared/src/urlDiagnostics.ts new file mode 100644 index 00000000000..6705e1ee7b4 --- /dev/null +++ b/packages/shared/src/urlDiagnostics.ts @@ -0,0 +1,19 @@ +export interface UrlDiagnostics { + readonly inputLength: number; + readonly protocol?: string; + readonly hostname?: string; +} + +export function getUrlDiagnostics(input: string): UrlDiagnostics { + const inputLength = input.length; + try { + const url = new URL(input); + return { + inputLength, + protocol: url.protocol, + hostname: url.hostname, + }; + } catch { + return { inputLength }; + } +}