fix(ui): open app deep links from chat after confirmation (#2932)
* fix(ui): open app deep links from chat after confirmation DOMPurify's default URI policy stripped href from anchors with custom application schemes (obsidian://, vscode://, ...), so every app link rendered in chat was dead across web, desktop, VS Code, and mobile. - Classify safe app-link schemes in lib/url.ts (browser-handled, scriptable, webview-internal, network, and self-deep-link schemes stay excluded) and let openExternalUrl accept them - Keep app-link hrefs through the markdown sanitize hook - Intercept app-link clicks in the markdown renderer and route them through a confirmation dialog (Trust and open / Open once, dismiss to cancel) mounted in the desktop/web app root and the mobile shell - Persist per-device trusted schemes in a zustand store; trusted schemes open without asking again * feat(settings): manage trusted app link schemes in General Add an App links section to Settings > General listing the application schemes trusted on this device with a delete action; removing a scheme restores the confirmation dialog for it. Register the section in settings search. * fix(ui): enforce app link confirmation * fix(ui): handle app links by runtime * fix(vscode): keep app links unsupported * fix(settings): clarify trusted app links --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a5b0272f01
commit
3a78d86248
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
mock.module('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
|
||||
const {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} = await import('./appLinkConfirmation');
|
||||
|
||||
describe('AppLinkConfirmDialog', () => {
|
||||
beforeEach(() => {
|
||||
if (getAppLinkConfirmationSnapshot()) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps cancel visible and focused beside both open choices', () => {
|
||||
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<AppLinkConfirmDialog />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('>Cancel</button>');
|
||||
expect(markup).toContain('autofocus=""');
|
||||
expect(markup).toContain('>Open once</button>');
|
||||
expect(markup).toContain('>Trust and open</button>');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getUrlScheme } from '@/lib/url';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
settleAppLinkConfirmation,
|
||||
subscribeAppLinkConfirmation,
|
||||
type AppLinkConfirmationChoice,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
/**
|
||||
* App-level dialog confirming application deep links (obsidian://, vscode://,
|
||||
* ...) rendered in chat markdown before the OS is asked to open them.
|
||||
* Dismissing via the close button, Escape, or the backdrop cancels the open.
|
||||
*/
|
||||
export const AppLinkConfirmDialog = () => {
|
||||
const { t } = useI18n();
|
||||
const request = React.useSyncExternalStore(
|
||||
subscribeAppLinkConfirmation,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
);
|
||||
|
||||
const url = request?.url ?? '';
|
||||
const scheme = getUrlScheme(url) ?? '';
|
||||
|
||||
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
|
||||
settleAppLinkConfirmation(choice);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(request)}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
settle('cancel');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{scheme
|
||||
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
|
||||
: t('chat.appLink.confirm.descriptionPlain')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
|
||||
{url}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
|
||||
{t('chat.appLink.confirm.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => settle('trust')}>
|
||||
{t('chat.appLink.confirm.trustAndOpen')}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => settle('open')}>
|
||||
{t('chat.appLink.confirm.open')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,10 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -55,7 +57,7 @@ const useCurrentMermaidTheme = () => {
|
||||
: fallbackLight);
|
||||
};
|
||||
|
||||
const useExternalLinkInteractions = ({
|
||||
const useLinkInteractions = ({
|
||||
containerRef,
|
||||
enabled,
|
||||
}: {
|
||||
@@ -63,48 +65,16 @@ const useExternalLinkInteractions = ({
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(href);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
return attachAppLinkInteractions(container, {
|
||||
allowExternalHttp: enabled !== false,
|
||||
openAppLink: (href) => void openAppLinkWithConfirmation(href),
|
||||
openExternalHttp: (href) => void openExternalUrl(href),
|
||||
});
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
@@ -969,7 +939,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences && !isStreaming,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
useLinkInteractions({ containerRef });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
@@ -1020,6 +990,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
content: string;
|
||||
className?: string;
|
||||
variant?: MarkdownVariant;
|
||||
// App links remain confirmed even where ordinary HTTP link handling is off.
|
||||
disableLinkSafety?: boolean;
|
||||
stripFrontmatter?: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -1061,7 +1032,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
describe('app link confirmation', () => {
|
||||
beforeEach(() => {
|
||||
useAppLinkTrustStore.setState({ trustedSchemes: [] });
|
||||
const pending = getAppLinkConfirmationSnapshot();
|
||||
if (pending) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('opens trusted schemes without asking', async () => {
|
||||
useAppLinkTrustStore.getState().trustScheme('obsidian');
|
||||
|
||||
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
|
||||
});
|
||||
|
||||
test('asks once and trusts the scheme when the user chooses trust', async () => {
|
||||
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
|
||||
|
||||
settleAppLinkConfirmation('trust');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
|
||||
});
|
||||
|
||||
test('cancel opens nothing and keeps the scheme untrusted', async () => {
|
||||
const pending = openAppLinkWithConfirmation('notion://note/xyz');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
|
||||
});
|
||||
|
||||
test('a newer request cancels the pending one', async () => {
|
||||
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
|
||||
const firstChoice = first.then(
|
||||
() => 'settled',
|
||||
() => 'settled',
|
||||
);
|
||||
const second = openAppLinkWithConfirmation('linear://open/1');
|
||||
|
||||
expect(await firstChoice).toBe('settled');
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
|
||||
|
||||
settleAppLinkConfirmation('open');
|
||||
await second;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
|
||||
|
||||
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
|
||||
|
||||
type PendingAppLinkRequest = {
|
||||
url: string;
|
||||
resolve: (choice: AppLinkConfirmationChoice) => void;
|
||||
};
|
||||
|
||||
let pendingRequest: PendingAppLinkRequest | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emitChange = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask the user (via the app-level confirmation dialog) whether an application
|
||||
* deep link may be opened. Resolves immediately when the scheme was trusted
|
||||
* earlier. Only one request is active at a time; a new request cancels the
|
||||
* pending one.
|
||||
*/
|
||||
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
|
||||
const scheme = getUrlScheme(url);
|
||||
if (!scheme) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const trustStore = useAppLinkTrustStore.getState();
|
||||
if (trustStore.isSchemeTrusted(scheme)) {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
|
||||
if (pendingRequest) {
|
||||
pendingRequest.resolve('cancel');
|
||||
}
|
||||
|
||||
return new Promise<AppLinkConfirmationChoice>((resolve) => {
|
||||
pendingRequest = { url, resolve };
|
||||
emitChange();
|
||||
}).then((choice) => {
|
||||
if (choice === 'trust') {
|
||||
useAppLinkTrustStore.getState().trustScheme(scheme);
|
||||
}
|
||||
if (choice === 'open' || choice === 'trust') {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
|
||||
const request = pendingRequest;
|
||||
pendingRequest = null;
|
||||
emitChange();
|
||||
request?.resolve(choice);
|
||||
};
|
||||
|
||||
export const subscribeAppLinkConfirmation = subscribe;
|
||||
export const getAppLinkConfirmationSnapshot = getSnapshot;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
|
||||
const TestElement = class Element {};
|
||||
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
|
||||
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
|
||||
|
||||
class TestAnchor extends HTMLAnchorElement {
|
||||
constructor(private readonly rawHref: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return name === 'href' ? this.rawHref : null;
|
||||
}
|
||||
|
||||
closest(): TestAnchor {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class TestContainer {
|
||||
listeners = new Map<string, EventListener>();
|
||||
|
||||
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
// SAFETY: dispatch constructs every mouse field read by the production listener.
|
||||
this.listeners.set(name, (event) => listener(event as MouseEvent));
|
||||
}
|
||||
|
||||
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
void listener;
|
||||
this.listeners.delete(name);
|
||||
}
|
||||
|
||||
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
|
||||
const event = new Event(name, { cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
target: { value: new TestAnchor(href) },
|
||||
button: { value: init.button ?? 0 },
|
||||
metaKey: { value: init.metaKey ?? false },
|
||||
ctrlKey: { value: init.ctrlKey ?? false },
|
||||
altKey: { value: init.altKey ?? false },
|
||||
shiftKey: { value: init.shiftKey ?? false },
|
||||
});
|
||||
this.listeners.get(name)?.(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
const setup = (allowExternalHttp = true) => {
|
||||
const container = new TestContainer();
|
||||
const appLinks: string[] = [];
|
||||
const httpLinks: string[] = [];
|
||||
const cleanup = attachAppLinkInteractions(container, {
|
||||
allowExternalHttp,
|
||||
openAppLink: (url) => appLinks.push(url),
|
||||
openExternalHttp: (url) => httpLinks.push(url),
|
||||
});
|
||||
return { container, appLinks, httpLinks, cleanup };
|
||||
};
|
||||
|
||||
describe('app link interactions', () => {
|
||||
test('confirms plain, modifier, and middle-click activations', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([href, href, href]);
|
||||
});
|
||||
|
||||
test('blocks drag activation without opening immediately', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
|
||||
const enabled = setup();
|
||||
const disabled = setup(false);
|
||||
const href = 'https://example.com';
|
||||
|
||||
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
|
||||
expect(enabled.httpLinks).toEqual([href]);
|
||||
expect(disabled.httpLinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
|
||||
|
||||
type AppLinkInteractionOptions = {
|
||||
allowExternalHttp: boolean;
|
||||
openAppLink: (url: string) => void;
|
||||
openExternalHttp: (url: string) => void;
|
||||
};
|
||||
|
||||
type LinkInteractionContainer = {
|
||||
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
};
|
||||
|
||||
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return null;
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return null;
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
|
||||
return anchor;
|
||||
};
|
||||
|
||||
const interceptAppLink = (
|
||||
event: MouseEvent | DragEvent,
|
||||
openAppLink?: (url: string) => void,
|
||||
): boolean => {
|
||||
if (event.defaultPrevented) return false;
|
||||
const anchor = findLink(event);
|
||||
const href = anchor?.getAttribute('href') ?? '';
|
||||
if (!isAppLinkUrl(href)) return false;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openAppLink?.(href);
|
||||
return true;
|
||||
};
|
||||
|
||||
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
|
||||
event.button === 0
|
||||
&& !event.metaKey
|
||||
&& !event.ctrlKey
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey
|
||||
);
|
||||
|
||||
export const attachAppLinkInteractions = (
|
||||
container: LinkInteractionContainer,
|
||||
options: AppLinkInteractionOptions,
|
||||
): (() => void) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (interceptAppLink(event, options.openAppLink)) return;
|
||||
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
|
||||
|
||||
const href = findLink(event)?.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
options.openExternalHttp(href);
|
||||
};
|
||||
const handleAuxClick = (event: MouseEvent) => {
|
||||
if (event.button === 1) interceptAppLink(event, options.openAppLink);
|
||||
};
|
||||
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
|
||||
interceptAppLink(event);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('auxclick', handleAuxClick);
|
||||
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('auxclick', handleAuxClick);
|
||||
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,43 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type SanitizeAttribute = {
|
||||
attrName: string;
|
||||
attrValue: string;
|
||||
forceKeepAttr?: boolean;
|
||||
};
|
||||
|
||||
class TestAnchorElement {
|
||||
target = '';
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
if (name === 'target') this.target = value;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeHooks: {
|
||||
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
|
||||
afterSanitizeAttributes?: (node: unknown) => void;
|
||||
} = {};
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {},
|
||||
HTMLAnchorElement: TestAnchorElement,
|
||||
});
|
||||
|
||||
mock.module('dompurify', () => ({
|
||||
default: {
|
||||
isSupported: true,
|
||||
addHook: () => undefined,
|
||||
sanitize: (html: string) => html,
|
||||
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
|
||||
sanitizeHooks[name] = hook;
|
||||
},
|
||||
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
|
||||
const anchor = new TestAnchorElement();
|
||||
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
|
||||
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
|
||||
sanitizeHooks.afterSanitizeAttributes?.(anchor);
|
||||
|
||||
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown-worker', () => ({
|
||||
@@ -40,6 +73,21 @@ describe('markdown sanitization', () => {
|
||||
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
|
||||
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps app and local file links while stripping blocked schemes', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[app](obsidian://open?vault=Notebook)',
|
||||
'[file](file:///workspace/notes.md)',
|
||||
'[script](javascript:alert(1))',
|
||||
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
|
||||
].join('\n\n'), 'inline');
|
||||
|
||||
expect(html).toContain('href="obsidian://open?vault=Notebook"');
|
||||
expect(html).toContain('href="file:///workspace/notes.md"');
|
||||
expect(html).not.toContain('href="javascript:alert(1)"');
|
||||
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isAppLinkUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
@@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => {
|
||||
sanitizeHookInstalled = true;
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
|
||||
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
// DOMPurify's default URI policy strips custom application schemes
|
||||
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
|
||||
// stay excluded via isAppLinkUrl and clicks go through confirmation.
|
||||
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
});
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return;
|
||||
@@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parseBlock = async (
|
||||
block: MarkdownBlock,
|
||||
imageMode: MarkdownImageMode,
|
||||
): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
@@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
|
||||
export const renderMarkdownSync = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): string => {
|
||||
if (!text) return '';
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = parser.parse(text) as string;
|
||||
|
||||
@@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- Assistant markdown treats raw HTML as inert visible text. The final generated
|
||||
HTML is sanitized as defense in depth, with script and style elements
|
||||
forbidden, so message content cannot inject active DOM or application-wide
|
||||
CSS into any runtime surface.
|
||||
CSS into any runtime surface. Safe custom application links go through the
|
||||
app-link confirmation flow in every supported renderer, including VS Code.
|
||||
- Final assistant Markdown rendering is independent from image gallery
|
||||
extraction: gallery presence never changes the chat body. Assistant image
|
||||
syntax consistently renders as a shared image icon followed by its filename,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
|
||||
/**
|
||||
* Security section for application deep links (obsidian://, notion://, ...)
|
||||
* that the user chose to always allow from chat. Removing a scheme restores
|
||||
* the confirmation dialog for it.
|
||||
*/
|
||||
export const AppLinkSecuritySettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const trustedSchemes = useAppLinkTrustStore((state) => state.trustedSchemes);
|
||||
const removeTrustedScheme = useAppLinkTrustStore((state) => state.removeTrustedScheme);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.openchamber.appLinks.title')}
|
||||
description={t('settings.openchamber.appLinks.info')}
|
||||
>
|
||||
<div className="space-y-1" data-settings-item="general.app-links">
|
||||
{trustedSchemes.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.openchamber.appLinks.empty')}
|
||||
</p>
|
||||
) : (
|
||||
trustedSchemes.map((scheme) => (
|
||||
<div key={scheme} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span className="min-w-0 truncate font-mono text-[13px]">{`${scheme}://`}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => removeTrustedScheme(scheme)}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('settings.openchamber.appLinks.removeAria', { scheme: `${scheme}://` })}
|
||||
>
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
||||
import { AboutSettings } from './AboutSettings';
|
||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||
import { PasskeySettings } from './PasskeySettings';
|
||||
import { AppLinkSecuritySettings } from './AppLinkSecuritySettings';
|
||||
import { DefaultsSettings } from './DefaultsSettings';
|
||||
import { GitSettings } from './GitSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
@@ -55,6 +56,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
{!isVSCode && <OpenCodeCliSettings />}
|
||||
{!isVSCode && <OpenChamberToolsSettings />}
|
||||
<SessionRetentionSettings />
|
||||
<AppLinkSecuritySettings />
|
||||
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
|
||||
{showAbout && <AboutSettings />}
|
||||
</SettingsPageLayout>
|
||||
@@ -145,6 +147,7 @@ const GeneralSectionContent: React.FC = () => {
|
||||
<>
|
||||
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
|
||||
{showPasskeySettings && <PasskeySettings />}
|
||||
<AppLinkSecuritySettings />
|
||||
{!isVSCode && <OpenCodeCliSettings />}
|
||||
{!isVSCode && <OpenChamberToolsSettings />}
|
||||
<OpenChamberVisualSettings visibleSettings={[
|
||||
|
||||
Reference in New Issue
Block a user