diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index e8b56a49..603fc6e5 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -20,14 +20,14 @@ Rule of thumb: CSI talks to the screen, OSC talks to the application hosting the OSC sequences are introduced by `ESC ]` and terminated by either `BEL` (`\x07`) or `ST` (`ESC \`). A `BEL` that terminates an OSC is part of that OSC sequence, not a standalone bell notification. Both terminators are accepted across all supported sequences, and the parser handles split chunks across PTY reads. -Supported OSCs are parsed at the PTY data boundary in the platform adapter: +State-driving and security-sensitive OSCs are parsed at the PTY data boundary in the platform adapter: - VS Code: in the extension host (`message-router.ts` / `pty-manager.ts`), before `pty:data` is forwarded to the webview. - Standalone and fake adapters: in the frontend adapter, before xterm.js sees the bytes. -After parsing, supported sequences are consumed and not re-emitted. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) are also consumed and ignored. The platform sends two streams to the webview: +After parsing, state-driving supported sequences are consumed and not re-emitted. `OSC 8` hyperlinks are the exception: the parser leaves them in `pty:data` so xterm.js owns hyperlink regions and hover rendering, while Dormouse supplies the activation-confirmation handler. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) are also consumed and ignored. The platform sends two streams to the webview: -- `pty:data` — terminal output with supported OSCs already parsed/stripped. Feeds xterm.js. +- `pty:data` — terminal output with state-driving supported OSCs already parsed/stripped and `OSC 8` hyperlinks preserved. Feeds xterm.js. - `terminal:semanticEvents` — normalized semantic events parsed in the platform (CWD, prompt/command boundaries, titles). Feeds `TerminalPaneState`; command boundaries also feed the command-exit alert track defined in `docs/specs/alert.md`. - Notification-derived state is delivered through `AlertManager` calls / `alert:state` messages, not through `pty:data`. @@ -48,6 +48,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c | `OSC 0 ; ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 2 ; <title> ST` | Window title | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 7 ; file://host/path ST` | CWD (xterm-style URI) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 8 ; <params> ; <URI> ST ... OSC 8 ; ; ST` | Explicit hyperlink region; passed through to xterm.js for rendering, then opened only after Dormouse shows the real target in a confirmation dialog. | This spec | | `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) | | `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) | | `OSC 9 ; 9 ; <cwd> ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | @@ -61,6 +62,20 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c Some sequences are dual-purpose. The notification rows for `OSC 9 ; <message> ST`, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in `terminal-state.md` — see its [Title candidate diagnostics](terminal-state.md#supported-osc-inputs) table. Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored for the diagnostic popup only. The OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes a title candidate. +### OSC 8 hyperlinks + +`OSC 8 ; <params> ; <URI> ST` starts a hyperlink region and `OSC 8 ; ; ST` closes it. `params` may be empty or include `id=<group-id>` for multi-line/shared link regions. Dormouse does not parse the `params` or URI at the PTY boundary; it passes the sequence through to xterm.js. + +`terminal-lifecycle.ts` sets xterm.js's `linkHandler` so activation never opens directly. Every click opens Dormouse's external-link confirmation dialog first. The dialog must show the full target URI from the OSC sequence, the URI scheme, and a primary `Open URL` action plus a cancel action. Cancel is the safe default. Long targets wrap and scroll instead of truncating so users can inspect deceptive link text. + +URI policy: + +- Openable after confirmation: any absolute URI with a scheme, including `http:`, `https:`, `mailto:`, `file:`, and custom app schemes such as `vscode:`. +- Blocked: malformed URIs, control-character-bearing targets, and browser-executable or opaque pseudo-schemes (`javascript:`, `data:`, `blob:`, `about:`). +- Blocked targets are not silently dropped. They still open the dialog in a non-openable state with the full target and reason visible, and `Open URL` disabled. + +VS Code revalidates in the extension host before `vscode.env.openExternal`; standalone and fake adapters also revalidate before opening. The frontend dialog is a user-consent affordance, not the security boundary. + ## Supported CSI The vast majority of CSI handling is delegated to xterm.js. Dormouse only intervenes in the cases below — either to answer a query itself (so the response shape is under our control), to observe a state change xterm.js processes, to enable an xterm.js feature, or to filter replay output. @@ -135,6 +150,7 @@ This list is non-exhaustive. Any iTerm2-compatibility OSC family that Dormouse c - xterm control sequences (OSC 0 / 2 / 7): https://invisible-island.net/xterm/ctlseqs/ctlseqs.html - VS Code shell integration sequences (OSC 633): https://code.visualstudio.com/docs/terminal/shell-integration - Windows Terminal CWD OSC 9;9: https://learn.microsoft.com/en-us/windows/terminal/tutorials/new-tab-same-directory +- xterm.js OSC 8 link handling: https://xtermjs.org/docs/guides/link-handling/ - kitty desktop notifications (OSC 99): https://sw.kovidgoyal.net/kitty/desktop-notifications/ - kitty keyboard protocol: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - WezTerm escape sequences (OSC 777): https://wezterm.org/escape-sequences.html diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 85e424cb..980ca208 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -77,6 +77,7 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o | `pty:getCwd` | Query PTY working directory (request-response via requestId) | | `pty:getScrollback` | Query PTY scrollback buffer (request-response via requestId) | | `pty:getShells` | Query available shells (request-response via requestId) | +| `dormouse:openExternal` | Request the host to open a user-confirmed external URI from an OSC 8 hyperlink. Hosts must revalidate and reject malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:`). | | `dormouse:init` | Trigger resume: get PTY list + replay data | | `dormouse:saveState` | Frontend persisting session state | | `dormouse:flushSessionSaveDone` | Ack for host-triggered flush (matched by requestId) | @@ -96,7 +97,7 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o | Message | Purpose | |---------|---------| -| `pty:data` | PTY output after supported OSC sequences have been parsed/stripped (routed only to owning router) | +| `pty:data` | PTY output after state-driving supported OSC sequences have been parsed/stripped; `OSC 8` hyperlinks are preserved for xterm.js (routed only to owning router) | | `pty:exit` | PTY process exited (with exitCode) | | `terminal:semanticEvents` | Normalized CWD/title/prompt/command events parsed in the host from live PTY data | | `pty:list` | List of all resumable PTYs (response to `dormouse:init`) | diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx new file mode 100644 index 00000000..c90cf3e6 --- /dev/null +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -0,0 +1,218 @@ +import { useRef } from 'react'; +import { ProhibitIcon, WarningOctagonIcon, XIcon } from '@phosphor-icons/react'; +import type { DisplayMatchVerdict, ExternalUriDecision } from '../lib/external-links'; +import { + ModalOverlay, + ModalSurface, + modalActionButton, + modalIconButton, + useModalFocusTrap, +} from './design'; + +export interface ExternalLinkDialogRequest { + uri: string; + displayText: string; + verdict: DisplayMatchVerdict; + decision: ExternalUriDecision; +} + +// "Open ___" button label suffix. The title is uniformly "Confirm open" and +// doesn't vary with scheme; the button is the only place the scheme noun +// appears, so the user sees what they're committing to next to their cursor. +function pickOpenButtonNoun(scheme: string, uri: string): React.ReactNode { + switch (scheme) { + case 'http': + case 'https': + return 'URL'; + case 'file': + return 'file'; + case 'mailto': + return 'email'; + case 'tel': + return 'phone app'; + case 'sms': + return 'SMS app'; + default: + return <code className="font-mono">{schemePrefix(scheme, uri)}</code>; + } +} + +function schemePrefix(scheme: string, uri: string): string { + return uri.slice(scheme.length + 1).startsWith('//') ? `${scheme}://` : `${scheme}:`; +} + +export function ExternalLinkDialog({ + request, + onCancel, + onConfirm, +}: { + request: ExternalLinkDialogRequest; + onCancel: () => void; + onConfirm: () => void; +}) { + const dialogRef = useRef<HTMLDivElement>(null); + const primaryButtonRef = useRef<HTMLButtonElement>(null); + const secondaryButtonRef = useRef<HTMLButtonElement>(null); + + const openableDecision = request.decision.status === 'openable' ? request.decision : null; + const blockedDecision = request.decision.status === 'blocked' ? request.decision : null; + const displayUri = request.decision.displayUri || request.uri; + const verdict = request.verdict; + const isDeceptive = verdict === 'deceptive'; + const buttonNoun = openableDecision + ? pickOpenButtonNoun(openableDecision.scheme, openableDecision.uri) + : 'URL'; + + useModalFocusTrap(dialogRef, { + // Deceptive case: focus the copy action so a default Enter doesn't dismiss + // silently. Everywhere else: focus the safe affordance (Cancel/Close). + initialFocusRef: isDeceptive ? primaryButtonRef : secondaryButtonRef, + onEscape: onCancel, + }); + + const handleCopy = () => { + void navigator.clipboard.writeText(request.uri); + onCancel(); + }; + + return ( + <ModalOverlay zIndex={9999} backdrop="strong" className="px-4 py-6"> + <ModalSurface + ref={dialogRef} + role="dialog" + aria-modal="true" + aria-labelledby="external-link-dialog-title" + elevation="modal" + className="w-full max-w-[34rem]" + > + <div className="flex items-start gap-3"> + <h2 + id="external-link-dialog-title" + className="min-w-0 flex-1 text-sm leading-5 text-foreground" + > + {isDeceptive ? ( + <DeceptiveTitle displayText={request.displayText} /> + ) : blockedDecision ? ( + <BlockedTitle reason={blockedDecision.reason} /> + ) : ( + <OpenTitle verdict={verdict} displayText={request.displayText} /> + )} + </h2> + <button + type="button" + aria-label="Close" + className={modalIconButton()} + onClick={onCancel} + > + <XIcon size={13} weight="bold" /> + </button> + </div> + + {/* Bordered nested box: explicit exception to the bg-only chrome rule + in DESIGN.md. The URL is the literal artifact the user is being + asked to scrutinize, and a framed box reads better than a bare + bg-shift in this high-stakes context. */} + <div className="mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded border border-border bg-app-bg px-2.5 py-2 text-sm leading-relaxed text-foreground"> + {displayUri} + </div> + + <div className="mt-4 flex justify-end gap-2 text-xs"> + {isDeceptive ? ( + <> + <button + ref={secondaryButtonRef} + type="button" + onClick={onCancel} + className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`} + > + Close + </button> + <button + ref={primaryButtonRef} + type="button" + onClick={handleCopy} + className={modalActionButton({ tone: 'primary' })} + > + Copy deceptive URL to clipboard + </button> + </> + ) : openableDecision ? ( + <> + <button + ref={secondaryButtonRef} + type="button" + onClick={onCancel} + className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`} + > + Cancel + </button> + <button + ref={primaryButtonRef} + type="button" + onClick={onConfirm} + className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`} + > + {'Open '}{buttonNoun} + </button> + </> + ) : ( + <button + ref={secondaryButtonRef} + type="button" + onClick={onCancel} + className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`} + > + Close + </button> + )} + </div> + </ModalSurface> + </ModalOverlay> + ); +} + +function OpenTitle({ + verdict, + displayText, +}: { + verdict: DisplayMatchVerdict; + displayText: string; +}) { + if (verdict === 'plain' && displayText.trim()) { + return ( + <> + Confirm open: <span className="font-semibold">{displayText.trim()}</span> + </> + ); + } + return <>Confirm open</>; +} + +function DeceptiveTitle({ displayText }: { displayText: string }) { + return ( + <span className="flex items-start gap-1.5"> + <WarningOctagonIcon + size={14} + weight="fill" + className="mt-px shrink-0 text-error" + aria-hidden + /> + <span className="leading-snug"> + Deceptive link text was{' '} + <span className="font-semibold">{displayText.trim()}</span>, URL was: + </span> + </span> + ); +} + +function BlockedTitle({ reason }: { reason: string }) { + return ( + <span className="flex items-start gap-1.5"> + <ProhibitIcon size={14} weight="bold" className="mt-px shrink-0 text-error" aria-hidden /> + <span className="leading-snug"> + Blocked.{' '} + <span className="text-muted">{reason}</span> + </span> + </span> + ); +} diff --git a/lib/src/components/ExternalLinkDialogHost.tsx b/lib/src/components/ExternalLinkDialogHost.tsx new file mode 100644 index 00000000..f7266906 --- /dev/null +++ b/lib/src/components/ExternalLinkDialogHost.tsx @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import { ExternalLinkDialog } from './ExternalLinkDialog'; +import { + clearExternalLinkConfirmation, + getExternalLinkConfirmationSnapshot, + subscribeExternalLinkConfirmation, +} from '../lib/external-link-confirmation'; +import { getPlatform } from '../lib/platform'; + +export function ExternalLinkDialogHost({ + onKeyboardActiveChange, +}: { + onKeyboardActiveChange: (active: boolean) => void; +}) { + const pending = useSyncExternalStore( + subscribeExternalLinkConfirmation, + getExternalLinkConfirmationSnapshot, + ); + + useEffect(() => { + onKeyboardActiveChange(pending !== null); + return () => onKeyboardActiveChange(false); + }, [onKeyboardActiveChange, pending]); + + const close = useCallback(() => { + clearExternalLinkConfirmation(); + }, []); + + const confirm = useCallback(() => { + const current = getExternalLinkConfirmationSnapshot(); + if (current?.decision.status === 'openable') { + getPlatform().openExternal?.(current.decision.uri); + } + clearExternalLinkConfirmation(); + }, []); + + if (!pending) return null; + + return ( + <ExternalLinkDialog + request={{ + uri: pending.uri, + displayText: pending.displayText, + verdict: pending.verdict, + decision: pending.decision, + }} + onCancel={close} + onConfirm={confirm} + /> + ); +} diff --git a/lib/src/components/KillConfirm.tsx b/lib/src/components/KillConfirm.tsx index ba409d45..b3b19e2d 100644 --- a/lib/src/components/KillConfirm.tsx +++ b/lib/src/components/KillConfirm.tsx @@ -1,6 +1,5 @@ -import { useLayoutEffect, useState } from 'react'; import { resolvePaneElement } from '../lib/spatial-nav'; -import { Shortcut } from './design'; +import { ModalOverlay, ModalSurface, Shortcut } from './design'; export type KillExit = 'shake' | 'confirm'; @@ -21,7 +20,7 @@ export function randomKillChar(): string { export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCancel?: () => void; exit?: KillExit }) { return ( - <div className={`bg-surface-raised border border-border px-6 py-4 rounded-lg text-center shadow-lg font-mono${exit === 'shake' ? ' motion-safe:animate-shake-x' : ''}`}> + <ModalSurface padding="spacious" align="center" className={exit === 'shake' ? 'motion-safe:animate-shake-x' : undefined}> <h2 className="text-base font-bold mb-3 text-foreground">Confirm kill</h2> <div className="bg-app-bg py-2 px-6 rounded border border-border inline-block mb-2"> <span @@ -39,7 +38,7 @@ export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCanc <span className="justify-self-start group-hover:text-foreground transition-colors">to cancel</span> </button> </div> - </div> + </ModalSurface> ); } @@ -48,44 +47,14 @@ export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: { paneElements: Map<string, HTMLElement>; onCancel: () => void; }) { - const exitClass = confirmKill.exit === 'confirm' ? ' kill-overlay-confirm' : ''; - const [rect, setRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null); - - // useLayoutEffect (not useEffect) so the initial measurement + re-render happens - // before the browser paints. Otherwise the centered-in-viewport fallback below - // flashes for one frame before the overlay snaps to the panel. - useLayoutEffect(() => { - const panelEl = resolvePaneElement(paneElements.get(confirmKill.id)); - if (!panelEl) { setRect(null); return; } - - const update = () => { - const r = panelEl.getBoundingClientRect(); - setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); - }; - - update(); - const ro = new ResizeObserver(update); - ro.observe(panelEl); - window.addEventListener('resize', update); - return () => { ro.disconnect(); window.removeEventListener('resize', update); }; - }, [confirmKill.id, paneElements]); - - if (rect) { - return ( - <div - style={{ position: 'fixed', top: rect.top, left: rect.left, width: rect.width, height: rect.height, zIndex: 100 }} - className={`flex items-center justify-center bg-app-bg/50 rounded${exitClass}`} - > - <KillConfirmCard char={confirmKill.char} onCancel={onCancel} exit={confirmKill.exit} /> - </div> - ); - } - + const panelEl = resolvePaneElement(paneElements.get(confirmKill.id)); return ( - <div className={`fixed inset-0 bg-app-bg/50 z-[100] flex items-center justify-center${exitClass}`}> + <ModalOverlay + targetElement={panelEl} + className={confirmKill.exit === 'confirm' ? 'kill-overlay-confirm' : undefined} + > <KillConfirmCard char={confirmKill.char} onCancel={onCancel} exit={confirmKill.exit} /> - </div> + </ModalOverlay> ); } - diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index f96a9a8e..c105f15f 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -8,6 +8,7 @@ import { } from 'dockview-react'; import 'dockview-react/dist/styles/dockview.css'; import { Baseboard } from './Baseboard'; +import { ExternalLinkDialogHost } from './ExternalLinkDialogHost'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; import { clearSessionAttention, @@ -772,6 +773,8 @@ export function Wall({ version={paneElementsVersion} /> + <ExternalLinkDialogHost onKeyboardActiveChange={setDialogKeyboardActive} /> + </div> </DialogKeyboardContext.Provider> </FreshlySpawnedContext.Provider> diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 7793a869..9cd64c13 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -1,6 +1,7 @@ import { clsx } from 'clsx'; import { tv, type VariantProps } from 'tailwind-variants'; -import type { HTMLAttributes, ReactNode } from 'react'; +import { forwardRef, useEffect, useLayoutEffect, useState } from 'react'; +import type { CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react'; // App-wide type scale, color strategy, and chrome conventions: see // docs/specs/theme.md and AGENTS.md. @@ -54,6 +55,214 @@ export const popupButton = tv({ export type PopupButtonVariants = VariantProps<typeof popupButton>; +export interface ModalRect { + top: number; + left: number; + width: number; + height: number; +} + +export const modalOverlay = tv({ + base: 'flex items-center justify-center', + variants: { + scope: { + viewport: 'fixed inset-0', + target: 'rounded', + }, + backdrop: { + standard: 'bg-app-bg/50', + strong: 'bg-app-bg/55', + }, + }, + defaultVariants: { scope: 'viewport', backdrop: 'standard' }, +}); + +export type ModalOverlayVariants = VariantProps<typeof modalOverlay>; + +export const modalSurface = tv({ + base: 'rounded-lg border border-border bg-surface-raised font-mono text-foreground shadow-lg', + variants: { + padding: { + compact: 'p-3', + default: 'p-4', + spacious: 'px-6 py-4', + }, + align: { + start: 'text-left', + center: 'text-center', + }, + elevation: { + dialog: 'shadow-lg', + modal: 'shadow-2xl', + }, + }, + defaultVariants: { padding: 'default', align: 'start', elevation: 'dialog' }, +}); + +export type ModalSurfaceVariants = VariantProps<typeof modalSurface>; + +export const modalActionButton = tv({ + base: 'rounded px-2 py-1.5 text-xs transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring disabled:cursor-not-allowed disabled:opacity-45', + variants: { + tone: { + primary: 'bg-header-active-bg text-header-active-fg', + secondary: 'border border-border text-muted hover:bg-header-inactive-bg hover:text-foreground', + }, + }, + defaultVariants: { tone: 'secondary' }, +}); + +export type ModalActionButtonVariants = VariantProps<typeof modalActionButton>; + +export const modalIconButton = tv({ + base: 'shrink-0 rounded p-0.5 text-muted transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring', +}); + +export function useMeasuredElementRect(element: HTMLElement | null): ModalRect | null { + const [rect, setRect] = useState<ModalRect | null>(null); + + useLayoutEffect(() => { + if (!element) { + setRect(null); + return; + } + + const update = () => { + const next = element.getBoundingClientRect(); + setRect({ + top: next.top, + left: next.left, + width: next.width, + height: next.height, + }); + }; + + update(); + const ro = new ResizeObserver(update); + ro.observe(element); + window.addEventListener('resize', update); + return () => { + ro.disconnect(); + window.removeEventListener('resize', update); + }; + }, [element]); + + return rect; +} + +export function ModalOverlay({ + children, + targetElement, + zIndex = 100, + backdrop = 'standard', + className, + style, + ...props +}: HTMLAttributes<HTMLDivElement> & ModalOverlayVariants & { + targetElement?: HTMLElement | null; + zIndex?: number; +}) { + const rect = useMeasuredElementRect(targetElement ?? null); + const overlayStyle: CSSProperties = rect + ? { + position: 'fixed', + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + zIndex, + ...style, + } + : { zIndex, ...style }; + + return ( + <div + className={clsx(modalOverlay({ scope: rect ? 'target' : 'viewport', backdrop }), className)} + style={overlayStyle} + {...props} + > + {children} + </div> + ); +} + +export type ModalSurfaceProps = HTMLAttributes<HTMLDivElement> & ModalSurfaceVariants; + +export const ModalSurface = forwardRef<HTMLDivElement, ModalSurfaceProps>(function ModalSurface({ + children, + padding, + align, + elevation, + className, + ...props +}, ref) { + return ( + <div + ref={ref} + className={clsx(modalSurface({ padding, align, elevation }), className)} + {...props} + > + {children} + </div> + ); +}); + +const MODAL_FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export function useModalFocusTrap<TDialog extends HTMLElement, TInitial extends HTMLElement>( + dialogRef: RefObject<TDialog | null>, + { + initialFocusRef, + onEscape, + }: { + initialFocusRef?: RefObject<TInitial | null>; + onEscape?: () => void; + } = {}, +): void { + useEffect(() => { + initialFocusRef?.current?.focus(); + }, [initialFocusRef]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const dialog = dialogRef.current; + if (!dialog) return; + + if (event.key === 'Escape') { + if (onEscape) { + event.preventDefault(); + event.stopPropagation(); + onEscape(); + } + return; + } + + if (event.key !== 'Tab') return; + + const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR)); + if (focusables.length === 0) return; + + const currentIndex = focusables.findIndex((item) => item === document.activeElement); + const nextIndex = currentIndex === -1 + ? 0 + : (currentIndex + (event.shiftKey ? -1 : 1) + focusables.length) % focusables.length; + + event.preventDefault(); + focusables[nextIndex]?.focus(); + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [dialogRef, onEscape]); +} + // Chrome buttons: icon-only and labeled triggers used in the standalone app // bar, plus the Windows/Linux native-style window controls. All inherit text // color from the surrounding chrome so they tint with the active/inactive diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index 27e80bf4..0c5de8ad 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -31,6 +31,7 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void { if (!c.apiRef.current) return; if (c.renamingRef.current) return; if (handleKillConfirm(e, c)) return; + if (c.dialogKeyboardActiveRef.current) return; if (handlePaneShortcuts(e, c, navHistory)) return; handlePaneNavigation(e, c, navHistory); }; diff --git a/lib/src/lib/external-link-confirmation.test.ts b/lib/src/lib/external-link-confirmation.test.ts new file mode 100644 index 00000000..92ea3f56 --- /dev/null +++ b/lib/src/lib/external-link-confirmation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + clearExternalLinkConfirmation, + getExternalLinkConfirmationSnapshot, + requestExternalLinkConfirmation, + subscribeExternalLinkConfirmation, +} from './external-link-confirmation'; + +describe('external link confirmation store', () => { + it('publishes an inspected pending link and clears it', () => { + let updates = 0; + const unsubscribe = subscribeExternalLinkConfirmation(() => { + updates += 1; + }); + + requestExternalLinkConfirmation('vscode://file/Users/dev/project/app.ts'); + expect(getExternalLinkConfirmationSnapshot()).toMatchObject({ + uri: 'vscode://file/Users/dev/project/app.ts', + displayText: '', + verdict: 'match', + decision: { + status: 'openable', + scheme: 'vscode', + }, + }); + + clearExternalLinkConfirmation(); + expect(getExternalLinkConfirmationSnapshot()).toBeNull(); + expect(updates).toBe(2); + + unsubscribe(); + }); + + it('classifies the link via displayText when provided', () => { + requestExternalLinkConfirmation('https://evil.com/phish', 'goog1e.com'); + expect(getExternalLinkConfirmationSnapshot()).toMatchObject({ + verdict: 'deceptive', + displayText: 'goog1e.com', + }); + clearExternalLinkConfirmation(); + + requestExternalLinkConfirmation('https://ci.example.com/x', 'see the report'); + expect(getExternalLinkConfirmationSnapshot()).toMatchObject({ + verdict: 'plain', + displayText: 'see the report', + }); + clearExternalLinkConfirmation(); + }); +}); diff --git a/lib/src/lib/external-link-confirmation.ts b/lib/src/lib/external-link-confirmation.ts new file mode 100644 index 00000000..2d715b2c --- /dev/null +++ b/lib/src/lib/external-link-confirmation.ts @@ -0,0 +1,47 @@ +import { + classifyDisplayMatch, + inspectExternalUri, + type DisplayMatchVerdict, + type ExternalUriDecision, +} from './external-links'; + +export interface PendingExternalLink { + uri: string; + displayText: string; + verdict: DisplayMatchVerdict; + decision: ExternalUriDecision; +} + +let pendingExternalLink: PendingExternalLink | null = null; +const listeners = new Set<() => void>(); + +export function requestExternalLinkConfirmation(uri: string, displayText: string = ''): void { + pendingExternalLink = { + uri, + displayText, + verdict: classifyDisplayMatch(uri, displayText), + decision: inspectExternalUri(uri), + }; + emitExternalLinkConfirmationChange(); +} + +export function clearExternalLinkConfirmation(): void { + if (!pendingExternalLink) return; + pendingExternalLink = null; + emitExternalLinkConfirmationChange(); +} + +export function getExternalLinkConfirmationSnapshot(): PendingExternalLink | null { + return pendingExternalLink; +} + +export function subscribeExternalLinkConfirmation(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function emitExternalLinkConfirmationChange(): void { + for (const listener of listeners) listener(); +} diff --git a/lib/src/lib/external-links.test.ts b/lib/src/lib/external-links.test.ts new file mode 100644 index 00000000..abbc48f9 --- /dev/null +++ b/lib/src/lib/external-links.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { classifyDisplayMatch, inspectExternalUri, normalizeExternalUri } from './external-links'; + +describe('normalizeExternalUri', () => { + it('allows absolute external URIs after inspection', () => { + expect(normalizeExternalUri('https://example.com/docs?q=mouse')).toBe('https://example.com/docs?q=mouse'); + expect(normalizeExternalUri(' http://example.com/path ')).toBe('http://example.com/path'); + expect(normalizeExternalUri('mailto:support@example.com')).toBe('mailto:support@example.com'); + expect(normalizeExternalUri('file:///Users/dev/report.html')).toBe('file:///Users/dev/report.html'); + expect(normalizeExternalUri('vscode://file/Users/dev/project/src/App.tsx:4:2')).toBe('vscode://file/Users/dev/project/src/App.tsx:4:2'); + }); + + it('rejects browser-executable or opaque pseudo schemes', () => { + expect(normalizeExternalUri('javascript:alert(1)')).toBeNull(); + expect(normalizeExternalUri('data:text/html,hello')).toBeNull(); + expect(normalizeExternalUri('blob:https://example.com/id')).toBeNull(); + expect(normalizeExternalUri('about:blank')).toBeNull(); + }); + + it('rejects malformed or control-character-bearing input', () => { + expect(normalizeExternalUri('not a url')).toBeNull(); + expect(normalizeExternalUri('https://example.com/\nnext')).toBeNull(); + expect(normalizeExternalUri('')).toBeNull(); + }); + + it('returns a displayable blocked reason', () => { + expect(inspectExternalUri('javascript:alert(1)')).toMatchObject({ + status: 'blocked', + scheme: 'javascript', + displayUri: 'javascript:alert(1)', + reason: expect.stringContaining('javascript:'), + }); + }); +}); + +describe('classifyDisplayMatch', () => { + it('returns match when displayed text equals the URL', () => { + expect(classifyDisplayMatch('https://example.com/foo', 'https://example.com/foo')).toBe('match'); + }); + + it('returns match when displayed text is empty (terminal auto-detected URL)', () => { + expect(classifyDisplayMatch('https://example.com/foo', '')).toBe('match'); + expect(classifyDisplayMatch('https://example.com/foo', ' ')).toBe('match'); + }); + + it('normalizes a trailing slash and case before deciding match', () => { + expect(classifyDisplayMatch('https://example.com/foo/', 'https://example.com/foo')).toBe('match'); + expect(classifyDisplayMatch('HTTPS://Example.com/Foo', 'https://example.com/Foo')).toBe('match'); + }); + + it('returns plain when displayed text is a human label', () => { + expect(classifyDisplayMatch('https://ci.example.com/x', 'see the report')).toBe('plain'); + expect(classifyDisplayMatch('https://github.com/foo', 'Click here')).toBe('plain'); + }); + + it('returns plain when the displayed URL has the same host as the actual URL', () => { + // Same host, different path — the label is a shorthand, not deceptive. + expect(classifyDisplayMatch('https://github.com/foo', 'github.com')).toBe('plain'); + expect(classifyDisplayMatch('https://github.com/foo', 'https://github.com')).toBe('plain'); + }); + + it('flags deceptive when the displayed URL targets a different host', () => { + expect(classifyDisplayMatch('https://evil.com/phish', 'https://goog1e.com')).toBe('deceptive'); + expect(classifyDisplayMatch('https://evil.com/phish', 'goog1e.com')).toBe('deceptive'); + expect(classifyDisplayMatch('https://evil.com/phish', 'https://google.com/maps')).toBe('deceptive'); + }); + + it('flags subdomain mismatch as deceptive (conservative side of the false-positive line)', () => { + expect(classifyDisplayMatch('https://github.com/foo', 'docs.github.com')).toBe('deceptive'); + }); + + it('treats label with embedded URL as plain unless the bare-domain pattern matches', () => { + // "Click for https://goog1e.com/free" contains a URL but is itself not URL-shaped. + // Conservative call: classify as plain. (We'd need to scan for embedded URLs to flag.) + expect(classifyDisplayMatch('https://evil.com/phish', 'Click for free money')).toBe('plain'); + }); +}); diff --git a/lib/src/lib/external-links.ts b/lib/src/lib/external-links.ts new file mode 100644 index 00000000..32731fcb --- /dev/null +++ b/lib/src/lib/external-links.ts @@ -0,0 +1,132 @@ +const BLOCKED_EXTERNAL_URI_PROTOCOLS = new Set(['javascript:', 'data:', 'blob:', 'about:']); + +export type DisplayMatchVerdict = 'match' | 'plain' | 'deceptive'; + +export type ExternalUriDecision = + | { + status: 'openable'; + rawUri: string; + uri: string; + scheme: string; + displayUri: string; + } + | { + status: 'blocked'; + rawUri: string; + scheme: string | null; + displayUri: string; + reason: string; + }; + +export function inspectExternalUri(input: string): ExternalUriDecision { + const trimmed = input.trim(); + if (!trimmed) { + return blocked(input, trimmed, null, 'No URL was provided.'); + } + + if (/[\x00-\x1f\x7f-\x9f]/.test(trimmed)) { + return blocked(input, trimmed, null, 'The URL contains control characters.'); + } + + try { + const uri = new URL(trimmed); + const scheme = uri.protocol.slice(0, -1); + if (BLOCKED_EXTERNAL_URI_PROTOCOLS.has(uri.protocol)) { + return blocked(input, trimmed, scheme, `${scheme}: URLs cannot be opened from terminal output.`); + } + return { + status: 'openable', + rawUri: input, + uri: uri.href, + scheme, + displayUri: trimmed, + }; + } catch { + return blocked(input, trimmed, null, 'The URL is not valid.'); + } +} + +export function normalizeExternalUri(input: string): string | null { + const decision = inspectExternalUri(input); + return decision.status === 'openable' ? decision.uri : null; +} + +// Three-tier classification of how the terminal-rendered link text compares to +// the actual URL it points to. The dialog uses the verdict to pick a title, +// gate the action, and decide what the user is really being asked. +// +// - match: visible text matches the URL after light normalization. Most +// non-OSC-8 clicks land here, because xterm passes the URL itself as the +// display text. +// - deceptive: visible text is URL-shaped (looks like a URL or bare domain) +// but resolves to a different host than the actual URL. The phishing shape. +// - plain: anything else — a legitimate human label like "see the report", +// or a sibling URL on the same host (different path/subdomain still counts +// as plain so we don't false-positive on redirects). +export function classifyDisplayMatch(uri: string, displayText: string): DisplayMatchVerdict { + const text = displayText.trim(); + if (!text) return 'match'; + + if (normalizeForMatch(text) === normalizeForMatch(uri)) return 'match'; + + const shapedHost = extractUrlShapedHost(text); + if (shapedHost === null) return 'plain'; + + const actualHost = safeHost(uri); + if (actualHost === null) return 'plain'; + + return shapedHost === actualHost ? 'plain' : 'deceptive'; +} + +function normalizeForMatch(value: string): string { + let v = value.trim().toLowerCase(); + // Drop a trailing slash so `https://x.com` and `https://x.com/` match. + if (v.endsWith('/')) v = v.slice(0, -1); + return v; +} + +function safeHost(uri: string): string | null { + try { + return new URL(uri).host.toLowerCase(); + } catch { + return null; + } +} + +// "URL-shaped" display text: either contains `://`, or looks like a bare +// domain (`goog1e.com`, `www.example.org/path`). Bare-domain detection is the +// classic phishing shape — the attacker shows what looks like a domain in a +// terminal label that actually links somewhere else. +const BARE_DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?:[/?#].*)?$/i; + +function extractUrlShapedHost(text: string): string | null { + const t = text.trim(); + if (t.includes('://')) { + try { + return new URL(t).host.toLowerCase(); + } catch { + return null; + } + } + if (BARE_DOMAIN_RE.test(t)) { + const slash = t.search(/[/?#]/); + const host = slash === -1 ? t : t.slice(0, slash); + return host.toLowerCase(); + } + return null; +} + +function blocked( + rawUri: string, + displayUri: string, + scheme: string | null, + reason: string, +): ExternalUriDecision { + return { + status: 'blocked', + rawUri, + scheme, + displayUri, + reason, + }; +} diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 0753deaa..a8cfd5f3 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -1,5 +1,6 @@ import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types'; import { AlertManager, type SessionStatus } from '../alert-manager'; +import { normalizeExternalUri } from '../external-links'; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -198,6 +199,11 @@ export class FakePtyAdapter implements PlatformAdapter { async readClipboardFilePaths(): Promise<string[] | null> { return null; } async readClipboardImageAsFilePath(): Promise<string | null> { return null; } + openExternal(uri: string): void { + const normalized = normalizeExternalUri(uri); + if (!normalized || typeof window === 'undefined') return; + window.open(normalized, '_blank', 'noopener,noreferrer'); + } requestInit(): void {} onPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {} diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 7b41c58b..ffa45d47 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -36,6 +36,10 @@ export interface PlatformAdapter { // Only present on adapters with a native (non-DOM) drag-drop source. Currently inert in Tauri; see diffplug/dormouse#38 and tauri-apps/tauri#14373. onFilesDropped?(handler: (paths: string[]) => void): () => void; + // Open a sanitized external URI. Implementations must revalidate because + // terminal output is untrusted. + openExternal?(uri: string): void; + // PTY event listeners onPtyData(handler: (detail: { id: string; data: string }) => void): void; offPtyData(handler: (detail: { id: string; data: string }) => void): void; diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 2800c721..6260a1cf 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -69,6 +69,17 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(postMessage).toHaveBeenCalledWith({ type: 'pty:kill', id: 'pane-1' }); }); + it('posts external hyperlink open requests to the extension host', () => { + const adapter = new VSCodeAdapter(); + + adapter.openExternal('https://example.com/docs'); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'dormouse:openExternal', + uri: 'https://example.com/docs', + }); + }); + it('parses replay buffers into semantic events and strips OSCs before forwarding', () => { const adapter = new VSCodeAdapter(); const replays: Array<{ id: string; data: string }> = []; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index d22ec84e..2bf4c65e 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -177,6 +177,10 @@ export class VSCodeAdapter implements PlatformAdapter { ); } + openExternal(uri: string): void { + this.vscode.postMessage({ type: 'dormouse:openExternal', uri }); + } + onPtyData(handler: (detail: { id: string; data: string }) => void): void { this.dataHandlers.add(handler); } diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 6db3c174..53b9aa6e 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,6 +1,7 @@ -import { Terminal } from '@xterm/xterm'; +import { Terminal, type IBufferRange } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { getPlatform } from './platform'; +import { requestExternalLinkConfirmation } from './external-link-confirmation'; import { attachMouseModeObserver } from './mouse-mode-observer'; import { bumpRenderTick, @@ -43,6 +44,28 @@ function seedProcessCwdAfterSpawn(id: string): void { void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwdByPtyId(id, cwd)); } +// Reconstructs the visible text from an OSC 8 hyperlink's buffer range. xterm +// passes the URL as the second arg to linkHandler.activate but not the rendered +// link text; we read it ourselves so the dialog can tell the user whether the +// label they clicked matched the URL. Wrapped lines concatenate without a +// separator (the wrap is visual, not a semantic break). +function readDisplayTextFromBuffer(terminal: Terminal, range: IBufferRange): string { + try { + const buffer = terminal.buffer.active; + let text = ''; + for (let y = range.start.y; y <= range.end.y; y++) { + const line = buffer.getLine(y - 1); + if (!line) continue; + const startCol = y === range.start.y ? range.start.x - 1 : 0; + const endCol = y === range.end.y ? range.end.x : undefined; + text += line.translateToString(true, startCol, endCol); + } + return text.trim(); + } catch { + return ''; + } +} + function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDivElement } { const styles = getComputedStyle(document.body); const editorFontSize = parseInt(styles.getPropertyValue('--vscode-editor-font-size'), 10) || 12; @@ -55,6 +78,14 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi cursorBlink: true, theme, vtExtensions: { kittyKeyboard: true }, + linkHandler: { + activate: (event, uri, range) => { + event.preventDefault(); + // Closure capture: `terminal` is defined by the time a click fires. + requestExternalLinkConfirmation(uri, readDisplayTextFromBuffer(terminal, range)); + }, + allowNonHttpProtocols: true, + }, }); const fit = new FitAddon(); diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts index 3d741138..11c2acdd 100644 --- a/lib/src/lib/terminal-protocol.test.ts +++ b/lib/src/lib/terminal-protocol.test.ts @@ -150,6 +150,15 @@ describe('TerminalProtocolParser', () => { expect(result.events).toEqual([]); }); + it('passes OSC 8 hyperlinks through to xterm for rendering', () => { + const parser = new TerminalProtocolParser(); + const hyperlink = '\x1b]8;id=docs;https://example.com/docs\x1b\\docs\x1b]8;;\x1b\\'; + const result = parser.process(`see ${hyperlink} now`); + + expect(result.visibleData).toBe(`see ${hyperlink} now`); + expect(result.events).toEqual([]); + }); + it('strips known unsupported iTerm2 and clipboard OSC sequences', () => { const parser = new TerminalProtocolParser(); const result = parser.process('a\x1b]52;c;SGVsbG8=\x07b\x1b]50;Monaco\x07c'); diff --git a/lib/src/stories/ExternalLinkDialog.stories.tsx b/lib/src/stories/ExternalLinkDialog.stories.tsx new file mode 100644 index 00000000..676557a5 --- /dev/null +++ b/lib/src/stories/ExternalLinkDialog.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ExternalLinkDialog } from '../components/ExternalLinkDialog'; +import { classifyDisplayMatch, inspectExternalUri } from '../lib/external-links'; + +function DialogStory({ uri, displayText }: { uri: string; displayText: string }) { + return ( + <div className="relative h-[360px] w-[680px] overflow-hidden rounded bg-app-bg font-mono text-terminal-fg"> + <div className="p-4 text-sm"> + <div>dev@dormouse:~/repo$ pnpm test</div> + <div className="text-muted">See the linked report for details.</div> + </div> + <ExternalLinkDialog + request={{ + uri, + displayText, + verdict: classifyDisplayMatch(uri, displayText), + decision: inspectExternalUri(uri), + }} + onCancel={() => {}} + onConfirm={() => {}} + /> + </div> + ); +} + +const meta: Meta<typeof DialogStory> = { + title: 'Components/ExternalLinkDialog', + component: DialogStory, + argTypes: { + uri: { control: 'text' }, + displayText: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj<typeof DialogStory>; + +// Match: terminal auto-detected the URL (no separate link text). +export const Https: Story = { + args: { + uri: 'https://github.com/diffplug/dormouse/pull/72?tab=files', + displayText: '', + }, +}; + +// Plain label: OSC 8 link with a human-readable label. Normal case. +export const PlainLabel: Story = { + args: { + uri: 'https://ci.example.com/builds/dormouse/jobs/test/report.html', + displayText: 'see the test report', + }, +}; + +// Deceptive: link text looks like a URL but resolves to a different host. +export const DeceptiveDomain: Story = { + args: { + uri: 'https://evil.example.com/phish', + displayText: 'goog1e.com', + }, +}; + +// Deceptive variant where the label is a full URL pretending to be the target. +export const DeceptiveFullUrl: Story = { + args: { + uri: 'https://evil.example.com/phish', + displayText: 'https://github.com/diffplug/dormouse', + }, +}; + +// Custom scheme + match (no separate link text). +export const CustomScheme: Story = { + args: { + uri: 'vscode://file/Users/dev/project/src/App.tsx:42:7', + displayText: '', + }, +}; + +// Custom scheme with a plain label. +export const CustomSchemePlain: Story = { + args: { + uri: 'vscode://file/Users/dev/project/src/App.tsx:42:7', + displayText: 'open in editor', + }, +}; + +// file:// URL — match case. +export const FileUrl: Story = { + args: { + uri: 'file:///Users/dev/project/tmp/report.html', + displayText: '', + }, +}; + +// mailto: with a label. +export const MailtoPlain: Story = { + args: { + uri: 'mailto:support@example.com', + displayText: 'contact the team', + }, +}; + +// Long URL stress test (match). +export const VeryLongUrl: Story = { + args: { + uri: 'https://ci.example.com/builds/dormouse/kitty-keyboard/jobs/terminal-osc-8-hyperlink-confirmation/artifacts/reports/playwright/index.html?runId=2026-05-18T23%3A41%3A02.441Z&attempt=7&sha=d96cc07f9f66ff72b7f89433cf571e9a13d4c081680&path=packages%2Flib%2Fsrc%2Fcomponents%2FExternalLinkDialog.tsx&label=the-terminal-output-rendered-this-link-with-a-short-friendly-label-but-the-real-url-is-intentionally-extremely-long-to-verify-wrapping-scrolling-and-full-target-review-before-opening&token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwiZGVtb19vbmx5Ijp0cnVlLCJwdXJwb3NlIjoic3Rvcnlib29rLWxvbmdfdXJsLXZpc3VhbC1jYXNlIn0', + displayText: '', + }, +}; + +// Blocked URL (javascript: scheme) — match between displayed text and URL. +export const Blocked: Story = { + args: { + uri: 'javascript:alert(document.cookie)', + displayText: '', + }, +}; + +// Blocked URL hidden behind a plain label. +export const BlockedWithLabel: Story = { + args: { + uri: 'javascript:alert(document.cookie)', + displayText: 'click for free shipping', + }, +}; diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index b5f89160..6adc229a 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -1,7 +1,9 @@ import { invoke as rawInvoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-shell"; import type { AlertStateDetail, PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types"; import { AlertManager, type SessionStatus } from "dormouse-lib/lib/alert-manager"; +import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -179,6 +181,14 @@ export class TauriAdapter implements PlatformAdapter { } catch { return null; } } + openExternal(uri: string): void { + const normalized = normalizeExternalUri(uri); + if (!normalized) return; + open(normalized).catch((err) => + console.error("[tauri-adapter] openExternal failed:", err), + ); + } + onFilesDropped(handler: (paths: string[]) => void): () => void { this.filesDroppedHandlers.add(handler); return () => { this.filesDroppedHandlers.delete(handler); }; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 2fad7967..28f29484 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -7,6 +7,7 @@ import { collectTerminalProtocolResponses, TerminalProtocolParser, } from '../../lib/src/lib/terminal-protocol'; +import { normalizeExternalUri } from '../../lib/src/lib/external-links'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { PersistedSession } from '../../lib/src/lib/session-types'; import type { WebviewMessage, ExtensionMessage } from './message-types'; @@ -270,6 +271,17 @@ export function attachRouter( webview.postMessage({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); }); break; + case 'dormouse:openExternal': { + const uri = normalizeExternalUri(msg.uri); + if (!uri) break; + void vscode.env.openExternal(vscode.Uri.parse(uri, true)).then( + (opened) => { + if (!opened) log.info(`[external-link] openExternal declined: ${uri}`); + }, + (err) => log.info(`[external-link] openExternal failed: ${err?.message ?? err}`), + ); + break; + } case 'dormouse:init': { // Webview has (re-)initialized — subscribe to live events. // Tear down previous subscriptions first (webview was destroyed and recreated). diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index b49e8b1c..278f9d23 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -12,6 +12,7 @@ export type WebviewMessage = | { type: 'pty:getShells'; requestId?: string } | { type: 'clipboard:readFiles'; requestId: string } | { type: 'clipboard:readImage'; requestId: string } + | { type: 'dormouse:openExternal'; uri: string } | { type: 'dormouse:init' } | { type: 'dormouse:saveState'; state: unknown } | { type: 'dormouse:flushSessionSaveDone'; requestId: string } diff --git a/website/src/data/dependencies.json b/website/src/data/dependencies.json index 95501017..500ff0f7 100644 --- a/website/src/data/dependencies.json +++ b/website/src/data/dependencies.json @@ -29,14 +29,14 @@ }, { "name": "@xterm/addon-fit", - "version": "0.11.0", + "version": "0.12.0-beta.216", "license": "MIT", "author": "The xterm.js authors", "homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme" }, { "name": "@xterm/xterm", - "version": "6.0.0", + "version": "6.1.0-beta.216", "license": "MIT", "author": "Christopher Jeffrey, SourceLair Private Company, xterm.js authors", "homepage": "https://github.com/xtermjs/xterm.js#readme"