From 922123f72bf0a5c6650ffdfeac06c09a6242de5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:54:02 -0700 Subject: [PATCH 1/6] Share redacted DPoP request targets Co-authored-by: codex --- packages/shared/src/dpop.test.ts | 13 +++++++++++++ packages/shared/src/dpop.ts | 9 +++++++++ 2 files changed, 22 insertions(+) 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)))); } From ef9ed1e31f0138d8d344d2e4aabda7318bdd6e48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:15:02 -0700 Subject: [PATCH 2/6] Share safe URL diagnostics Co-authored-by: codex --- packages/shared/package.json | 4 ++++ packages/shared/src/urlDiagnostics.test.ts | 24 ++++++++++++++++++++++ packages/shared/src/urlDiagnostics.ts | 19 +++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 packages/shared/src/urlDiagnostics.test.ts create mode 100644 packages/shared/src/urlDiagnostics.ts 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/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 }; + } +} From 1d5058df865265776fdcfad2e900b05250a0b1e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 05:55:15 -0700 Subject: [PATCH 3/6] fix(web): report preview action failures Co-authored-by: codex --- .../components/preview/PreviewMoreMenu.tsx | 33 +++- .../src/components/preview/PreviewView.tsx | 166 +++++++++++++++--- .../preview/reportPreviewActionFailure.ts | 16 ++ 3 files changed, 177 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/preview/reportPreviewActionFailure.ts 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..5b0f3bebd13 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -20,6 +20,7 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { reportPreviewActionFailure } from "./reportPreviewActionFailure"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -91,58 +92,91 @@ 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 { + operation = "open-session"; await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl, }); } - } catch { + } catch (cause) { + reportPreviewActionFailure( + { operation, threadKey, ...(tabId ? { tabId } : {}), url: 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 } : {}), url }, + cause, + ); + }); + }, [tabId, threadKey, url]); const handleCapture = useCallback( (record: boolean) => { @@ -180,6 +214,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 +238,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 +286,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 +306,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 +319,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 +392,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 +421,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 +466,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 +475,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 +499,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 +521,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 +553,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 +563,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 +602,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); return ( -
+
Date: Sat, 20 Jun 2026 06:01:11 -0700 Subject: [PATCH 4/6] fix(web): report preview open failures Co-authored-by: codex --- apps/web/src/components/preview/PreviewView.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 5b0f3bebd13..6faec35dc0a 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"; @@ -109,11 +110,17 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, rememberPreviewUrl(threadRef, resolvedUrl); } else { operation = "open-session"; - await openPreviewSession({ + const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl, }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportPreviewActionFailure( + { operation, threadKey, ...(tabId ? { tabId } : {}), url: targetUrl }, + result.cause, + ); + } } } catch (cause) { reportPreviewActionFailure( From 4439955d935458c6d08265a54908fa0f3bd65b96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:07:48 -0700 Subject: [PATCH 5/6] fix(web): redact preview URL failure context Co-authored-by: codex --- .../src/components/preview/PreviewView.tsx | 23 ++++++++++-- .../reportPreviewActionFailure.test.ts | 37 +++++++++++++++++++ .../preview/reportPreviewActionFailure.ts | 21 ++++++++++- 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/preview/reportPreviewActionFailure.test.ts diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 6faec35dc0a..038859f881a 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -21,7 +21,7 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; -import { reportPreviewActionFailure } from "./reportPreviewActionFailure"; +import { previewUrlFailureContext, reportPreviewActionFailure } from "./reportPreviewActionFailure"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -117,14 +117,24 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { reportPreviewActionFailure( - { operation, threadKey, ...(tabId ? { tabId } : {}), url: targetUrl }, + { + operation, + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(targetUrl), + }, result.cause, ); } } } catch (cause) { reportPreviewActionFailure( - { operation, threadKey, ...(tabId ? { tabId } : {}), url: targetUrl }, + { + operation, + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(targetUrl), + }, cause, ); // Server-side `failed` event renders the unreachable view. @@ -179,7 +189,12 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, if (!localApi || !url) return; void localApi.shell.openExternal(url).catch((cause) => { reportPreviewActionFailure( - { operation: "open-external", threadKey, ...(tabId ? { tabId } : {}), url }, + { + operation: "open-external", + threadKey, + ...(tabId ? { tabId } : {}), + ...previewUrlFailureContext(url), + }, cause, ); }); diff --git a/apps/web/src/components/preview/reportPreviewActionFailure.test.ts b/apps/web/src/components/preview/reportPreviewActionFailure.test.ts new file mode 100644 index 00000000000..9ae9648d1dc --- /dev/null +++ b/apps/web/src/components/preview/reportPreviewActionFailure.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { previewUrlFailureContext, reportPreviewActionFailure } from "./reportPreviewActionFailure"; + +describe("reportPreviewActionFailure", () => { + 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 index 01c35736f35..6286078acd4 100644 --- a/apps/web/src/components/preview/reportPreviewActionFailure.ts +++ b/apps/web/src/components/preview/reportPreviewActionFailure.ts @@ -2,12 +2,31 @@ export interface PreviewActionFailureContext { readonly operation: string; readonly threadKey?: string; readonly tabId?: string; - readonly url?: string; + readonly urlHostname?: string; + readonly urlLength?: number; + readonly urlProtocol?: string; readonly artifactPath?: string; readonly annotationId?: string; readonly trigger?: string; } +export function previewUrlFailureContext(url: string) { + let urlHostname: string | undefined; + let urlProtocol: string | undefined; + try { + const parsed = new URL(url); + urlHostname = parsed.hostname || undefined; + urlProtocol = parsed.protocol || undefined; + } catch { + // Invalid targets still retain a nonsecret input length for diagnostics. + } + return { + urlLength: url.length, + ...(urlHostname === undefined ? {} : { urlHostname }), + ...(urlProtocol === undefined ? {} : { urlProtocol }), + }; +} + export function reportPreviewActionFailure( context: PreviewActionFailureContext, cause: unknown, From 8fe62591f6c5f696ed1f4274d6a1dde6f89c3f35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:18:11 -0700 Subject: [PATCH 6/6] Reuse shared URL diagnostics Co-authored-by: codex --- .../preview/reportPreviewActionFailure.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/preview/reportPreviewActionFailure.ts b/apps/web/src/components/preview/reportPreviewActionFailure.ts index 6286078acd4..ceaac4cd1a9 100644 --- a/apps/web/src/components/preview/reportPreviewActionFailure.ts +++ b/apps/web/src/components/preview/reportPreviewActionFailure.ts @@ -1,3 +1,5 @@ +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; + export interface PreviewActionFailureContext { readonly operation: string; readonly threadKey?: string; @@ -11,19 +13,11 @@ export interface PreviewActionFailureContext { } export function previewUrlFailureContext(url: string) { - let urlHostname: string | undefined; - let urlProtocol: string | undefined; - try { - const parsed = new URL(url); - urlHostname = parsed.hostname || undefined; - urlProtocol = parsed.protocol || undefined; - } catch { - // Invalid targets still retain a nonsecret input length for diagnostics. - } + const diagnostics = getUrlDiagnostics(url); return { - urlLength: url.length, - ...(urlHostname === undefined ? {} : { urlHostname }), - ...(urlProtocol === undefined ? {} : { urlProtocol }), + urlLength: diagnostics.inputLength, + ...(diagnostics.hostname === undefined ? {} : { urlHostname: diagnostics.hostname }), + ...(diagnostics.protocol === undefined ? {} : { urlProtocol: diagnostics.protocol }), }; }