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:
ChangeHow
2026-08-23 01:53:21 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a5b0272f01
commit 3a78d86248
45 changed files with 909 additions and 53 deletions
+3
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
@@ -908,6 +909,7 @@ function App({ apis }: AppProps) {
isVSCodeRuntime={isVSCodeRuntime}
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
<AppLinkConfirmDialog />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
@@ -951,6 +953,7 @@ function App({ apis }: AppProps) {
<OpenCodeUpdateToast />
<MainLayout />
<Toaster />
<AppLinkConfirmDialog />
{!isBootShell && (
<>
<ConfigUpdateOverlay />
@@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
@@ -325,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<ElectronMiniChatContent config={config} />
<AppLinkConfirmDialog />
<Toaster />
</div>
</TooltipProvider>
+2
View File
@@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { ChatView } from '@/components/views/ChatView';
import { PlanView } from '@/components/views/PlanView';
import { SettingsView } from '@/components/views/SettingsView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
}} />
<AppLinkConfirmDialog />
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
{isInitialized ? <ConfigUpdateOverlay /> : null}
</div>
+3
View File
@@ -8,6 +8,7 @@ import { Toaster } from '@/components/ui/sonner';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
@@ -110,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<AgentManagerView />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
</div>
@@ -129,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<VSCodeLayout />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
<ConfigUpdateOverlay />
@@ -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={[
@@ -416,6 +416,10 @@ export const settingsDict = {
'settings.common.actions.cancel': 'Abbrechen',
'settings.common.actions.create': 'Erstellen',
'settings.common.actions.delete': 'Löschen',
'settings.openchamber.appLinks.title': 'Vertrauenswürdige App-Links',
'settings.openchamber.appLinks.info': 'Hier aufgeführte Links öffnen sich auf diesem Gerät ohne erneute Nachfrage. Bei anderen App-Links wird vor dem Öffnen immer nachgefragt.',
'settings.openchamber.appLinks.empty': 'Keine vertrauenswürdigen App-Links auf diesem Gerät. Wähle beim Öffnen eines Links „Vertrauen und öffnen“, um ihn hier hinzuzufügen.',
'settings.openchamber.appLinks.removeAria': 'Vertraute {scheme}-Links entfernen',
'settings.common.actions.reset': 'Zurücksetzen',
'settings.common.actions.rename': 'Umbenennen',
'settings.common.actions.duplicate': 'Duplizieren',
+6
View File
@@ -1392,6 +1392,12 @@ export const dict = {
'diffView.reviewDialog.toast.noSessionDirectory': 'Sitzungsverzeichnis ist nicht verfügbar',
'diffView.reviewDialog.toast.startFailed': 'Fehler beim Starten des Überprüfungsflusses',
'chat.history.loadOlder': 'Ältere Nachrichten laden',
'chat.appLink.confirm.title': 'Diesen Link in einer anderen App öffnen?',
'chat.appLink.confirm.description': 'Dieser Chat-Link verwendet das {scheme}-Protokoll und wird in einer anderen App geöffnet.',
'chat.appLink.confirm.descriptionPlain': 'Dieser Chat-Link wird in einer anderen App geöffnet.',
'chat.appLink.confirm.cancel': 'Abbrechen',
'chat.appLink.confirm.open': 'Einmal öffnen',
'chat.appLink.confirm.trustAndOpen': 'Vertrauen und öffnen',
'chat.autoReview.title': 'Code-Überprüfungs-Schleife läuft',
'chat.autoReview.status.waitingForReviewer': 'Warte auf Überprüfer',
'chat.autoReview.status.waitingForImplementer': 'Warte auf Implementierer',
@@ -433,6 +433,10 @@ export const settingsDict = {
'settings.common.actions.cancel': 'Cancel',
'settings.common.actions.create': 'Create',
'settings.common.actions.delete': 'Delete',
'settings.openchamber.appLinks.title': 'Trusted app links',
'settings.openchamber.appLinks.info': 'Links listed here open without asking again on this device. Other app links always ask before opening.',
'settings.openchamber.appLinks.empty': 'No trusted app links on this device. Choose "Trust and open" when opening a link to add it here.',
'settings.openchamber.appLinks.removeAria': 'Remove trusted {scheme} links',
'settings.common.actions.reset': 'Reset',
'settings.common.actions.rename': 'Rename',
'settings.common.actions.duplicate': 'Duplicate',
+6
View File
@@ -1549,6 +1549,12 @@ export const dict = {
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.history.loadOlder': 'Load older messages',
'chat.appLink.confirm.title': 'Open this link in another application?',
'chat.appLink.confirm.description': 'This chat link uses the {scheme} protocol and will open in another application.',
'chat.appLink.confirm.descriptionPlain': 'This chat link will open in another application.',
'chat.appLink.confirm.cancel': 'Cancel',
'chat.appLink.confirm.open': 'Open once',
'chat.appLink.confirm.trustAndOpen': 'Trust and open',
'chat.autoReview.title': 'Code review loop is running',
'chat.autoReview.status.waitingForReviewer': 'Waiting for reviewer',
'chat.autoReview.status.waitingForImplementer': 'Waiting for implementer',
@@ -401,6 +401,10 @@ export const settingsDict = {
"settings.common.actions.cancel": "Cancelar",
"settings.common.actions.create": "Crear",
"settings.common.actions.delete": "Eliminar",
"settings.openchamber.appLinks.title": "Enlaces de aplicaciones de confianza",
"settings.openchamber.appLinks.info": "Los enlaces de esta lista se abren sin volver a preguntar en este dispositivo. Los demás enlaces de aplicaciones siempre piden confirmación.",
"settings.openchamber.appLinks.empty": "No hay enlaces de aplicaciones de confianza en este dispositivo. Elige \"Confiar y abrir\" al abrir un enlace para añadirlo aquí.",
"settings.openchamber.appLinks.removeAria": "Quitar los enlaces {scheme} de confianza",
"settings.common.actions.reset": "Restablecer",
"settings.common.actions.rename": "Cambiar nombre",
"settings.common.actions.duplicate": "Duplicar",
+6
View File
@@ -1527,6 +1527,12 @@ export const dict: Record<I18nKey, string> = {
'chat.dictation.retry': 'Reintentar transcripción',
'chat.dictation.discard': 'Descartar grabación',
'chat.history.loadOlder': 'Cargar mensajes anteriores',
"chat.appLink.confirm.title": "¿Abrir este enlace en otra aplicación?",
"chat.appLink.confirm.description": "Este enlace del chat usa el protocolo {scheme} y se abrirá en otra aplicación.",
"chat.appLink.confirm.descriptionPlain": "Este enlace del chat se abrirá en otra aplicación.",
"chat.appLink.confirm.cancel": "Cancelar",
"chat.appLink.confirm.open": "Abrir una vez",
"chat.appLink.confirm.trustAndOpen": "Confiar y abrir",
'chat.autoReview.title': 'El ciclo de revisión de código está en curso',
'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor',
'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador',
@@ -324,6 +324,10 @@ export const settingsDict = {
'settings.common.actions.cancel': 'Annuler',
'settings.common.actions.create': 'Créer',
'settings.common.actions.delete': 'Supprimer',
'settings.openchamber.appLinks.title': 'Liens dapplication approuvés',
'settings.openchamber.appLinks.info': 'Les liens de cette liste souvrent sans nouvelle demande sur cet appareil. Les autres liens dapplication demandent toujours une confirmation.',
'settings.openchamber.appLinks.empty': 'Aucun lien dapplication approuvé sur cet appareil. Choisissez « Approuver et ouvrir » lors de louverture dun lien pour lajouter ici.',
'settings.openchamber.appLinks.removeAria': 'Supprimer les liens {scheme} approuvés',
'settings.common.actions.reset': 'Réinitialiser',
'settings.common.actions.rename': 'Rebaptiser',
'settings.common.actions.duplicate': 'Dupliquer',
+6
View File
@@ -1314,6 +1314,12 @@ export const dict = {
'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible',
'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue',
'chat.history.loadOlder': 'Charger les messages précédents',
'chat.appLink.confirm.title': 'Ouvrir ce lien dans une autre application ?',
'chat.appLink.confirm.description': "Ce lien de discussion utilise le protocole {scheme} et s'ouvrira dans une autre application.",
'chat.appLink.confirm.descriptionPlain': "Ce lien de discussion s'ouvrira dans une autre application.",
'chat.appLink.confirm.cancel': 'Annuler',
'chat.appLink.confirm.open': 'Ouvrir une fois',
'chat.appLink.confirm.trustAndOpen': 'Approuver et ouvrir',
'chat.autoReview.title': 'La boucle de revue de code est en cours',
'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer',
'chat.autoReview.status.waitingForImplementer': 'En attente de limplémenteur',
@@ -434,6 +434,10 @@ export const settingsDict = {
'settings.common.actions.cancel': 'キャンセル',
'settings.common.actions.create': '作成',
'settings.common.actions.delete': '削除',
'settings.openchamber.appLinks.title': '信頼済みのアプリリンク',
'settings.openchamber.appLinks.info': 'ここに表示されたリンクは、このデバイスでは次回から確認せずに開きます。その他のアプリリンクは開く前に必ず確認します。',
'settings.openchamber.appLinks.empty': 'このデバイスには信頼済みのアプリリンクがありません。リンクを開く際に「信頼して開く」を選ぶとここに追加されます。',
'settings.openchamber.appLinks.removeAria': '信頼済みの {scheme} リンクを削除',
'settings.common.actions.reset': 'リセット',
'settings.common.actions.rename': '名前変更',
'settings.common.actions.duplicate': '複製',
+6
View File
@@ -1554,6 +1554,12 @@ export const dict: Record<I18nKey, string> = {
'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。',
'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。',
'chat.history.loadOlder': '以前のメッセージを読み込む',
'chat.appLink.confirm.title': 'このリンクを別のアプリで開きますか?',
'chat.appLink.confirm.description': 'このチャットのリンクは {scheme} プロトコルを使用し、別のアプリで開かれます。',
'chat.appLink.confirm.descriptionPlain': 'このチャットのリンクは別のアプリで開かれます。',
'chat.appLink.confirm.cancel': 'キャンセル',
'chat.appLink.confirm.open': '一度だけ開く',
'chat.appLink.confirm.trustAndOpen': '信頼して開く',
'chat.autoReview.title': 'コードレビューループが実行中です',
'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中',
'chat.autoReview.status.waitingForImplementer': '実装者を待機中',
@@ -401,6 +401,10 @@ export const settingsDict = {
'settings.common.actions.cancel': '취소',
'settings.common.actions.create': '생성',
'settings.common.actions.delete': '삭제',
'settings.openchamber.appLinks.title': '신뢰한 앱 링크',
'settings.openchamber.appLinks.info': '여기에 표시된 링크는 이 기기에서 다시 묻지 않고 열립니다. 그 밖의 앱 링크는 열기 전에 항상 확인합니다.',
'settings.openchamber.appLinks.empty': '이 기기에 신뢰한 앱 링크가 없습니다. 링크를 열 때 "신뢰하고 열기"를 선택하면 여기에 추가됩니다.',
'settings.openchamber.appLinks.removeAria': '신뢰된 {scheme} 링크 제거',
'settings.common.actions.reset': '초기화',
'settings.common.actions.rename': '이름 변경',
'settings.common.actions.duplicate': '복제',
+6
View File
@@ -1551,6 +1551,12 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다',
'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다',
'chat.history.loadOlder': '이전 메시지 불러오기',
'chat.appLink.confirm.title': '이 링크를 다른 앱에서 열까요?',
'chat.appLink.confirm.description': '이 채팅 링크는 {scheme} 프로토콜을 사용하며 다른 앱에서 열립니다.',
'chat.appLink.confirm.descriptionPlain': '이 채팅 링크는 다른 앱에서 열립니다.',
'chat.appLink.confirm.cancel': '취소',
'chat.appLink.confirm.open': '한 번만 열기',
'chat.appLink.confirm.trustAndOpen': '신뢰하고 열기',
'chat.autoReview.title': '코드 리뷰 루프 실행 중',
'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중',
'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중',
@@ -216,6 +216,10 @@ export const settingsDict = {
'settings.common.actions.copyAll': 'Kopiuj wszystko',
'settings.common.actions.create': 'Utwórz',
'settings.common.actions.delete': 'Usuń',
'settings.openchamber.appLinks.title': 'Zaufane linki aplikacji',
'settings.openchamber.appLinks.info': 'Linki z tej listy otwierają się na tym urządzeniu bez ponownego pytania. Inne linki aplikacji zawsze wymagają potwierdzenia.',
'settings.openchamber.appLinks.empty': 'Brak zaufanych linków aplikacji na tym urządzeniu. Wybierz „Zaufaj i otwórz” podczas otwierania linku, aby dodać go tutaj.',
'settings.openchamber.appLinks.removeAria': 'Usuń zaufane linki {scheme}',
'settings.common.actions.duplicate': 'Duplikuj',
'settings.common.actions.import': 'Importuj',
'settings.common.actions.rename': 'Zmień nazwę',
+6
View File
@@ -1763,6 +1763,12 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny',
'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review',
'chat.history.loadOlder': 'Wczytaj starsze wiadomości',
'chat.appLink.confirm.title': 'Otworzyć ten link w innej aplikacji?',
'chat.appLink.confirm.description': 'Ten link z czatu używa protokołu {scheme} i zostanie otwarty w innej aplikacji.',
'chat.appLink.confirm.descriptionPlain': 'Ten link z czatu zostanie otwarty w innej aplikacji.',
'chat.appLink.confirm.cancel': 'Anuluj',
'chat.appLink.confirm.open': 'Otwórz raz',
'chat.appLink.confirm.trustAndOpen': 'Zaufaj i otwórz',
'chat.autoReview.title': 'Pętla code review trwa',
'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera',
'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora',
@@ -401,6 +401,10 @@ export const settingsDict = {
"settings.common.actions.cancel": "Cancelar",
"settings.common.actions.create": "Criar",
"settings.common.actions.delete": "Excluir",
"settings.openchamber.appLinks.title": "Links de aplicativos confiáveis",
"settings.openchamber.appLinks.info": "Os links desta lista abrem sem perguntar novamente neste dispositivo. Outros links de aplicativos sempre pedem confirmação antes de abrir.",
"settings.openchamber.appLinks.empty": "Não há links de aplicativos confiáveis neste dispositivo. Escolha \"Confiar e abrir\" ao abrir um link para adicioná-lo aqui.",
"settings.openchamber.appLinks.removeAria": "Remover links {scheme} confiáveis",
"settings.common.actions.reset": "Reiniciar",
"settings.common.actions.rename": "Renomear",
"settings.common.actions.duplicate": "Duplicar",
@@ -1527,6 +1527,12 @@ export const dict: Record<I18nKey, string> = {
'chat.dictation.retry': 'Tentar transcrever novamente',
'chat.dictation.discard': 'Descartar gravação',
'chat.history.loadOlder': 'Carregar mensagens anteriores',
"chat.appLink.confirm.title": "Abrir este link em outro aplicativo?",
"chat.appLink.confirm.description": "Este link do chat usa o protocolo {scheme} e será aberto em outro aplicativo.",
"chat.appLink.confirm.descriptionPlain": "Este link do chat será aberto em outro aplicativo.",
"chat.appLink.confirm.cancel": "Cancelar",
"chat.appLink.confirm.open": "Abrir uma vez",
"chat.appLink.confirm.trustAndOpen": "Confiar e abrir",
'chat.autoReview.title': 'O ciclo de revisão de código está em andamento',
'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor',
'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador',
@@ -401,6 +401,10 @@ export const settingsDict = {
"settings.common.actions.cancel": "Скасувати",
"settings.common.actions.create": "Створити",
"settings.common.actions.delete": "Видалити",
"settings.openchamber.appLinks.title": "Довірені посилання програм",
"settings.openchamber.appLinks.info": "Посилання в цьому списку відкриваються без повторного запиту на цьому пристрої. Для інших посилань програм ми завжди просимо підтвердження.",
"settings.openchamber.appLinks.empty": "На цьому пристрої ще немає довірених посилань програм. Виберіть «Довірити і відкрити» під час відкриття посилання, щоб додати його сюди.",
"settings.openchamber.appLinks.removeAria": "Видалити довірені посилання {scheme}",
"settings.common.actions.reset": "Скинути",
"settings.common.actions.rename": "Перейменувати",
"settings.common.actions.duplicate": "Дублювати",
+6
View File
@@ -1527,6 +1527,12 @@ export const dict: Record<I18nKey, string> = {
'chat.dictation.retry': 'Повторити розшифровку',
'chat.dictation.discard': 'Відхилити запис',
'chat.history.loadOlder': 'Завантажити ще',
"chat.appLink.confirm.title": "Відкрити це посилання в іншій програмі?",
"chat.appLink.confirm.description": "Це посилання з чату використовує протокол {scheme} і буде відкрито в іншій програмі.",
"chat.appLink.confirm.descriptionPlain": "Це посилання з чату буде відкрито в іншій програмі.",
"chat.appLink.confirm.cancel": "Скасувати",
"chat.appLink.confirm.open": "Відкрити один раз",
"chat.appLink.confirm.trustAndOpen": "Довірити і відкрити",
'chat.autoReview.title': 'Цикл код-ревʼю триває',
'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера',
'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора',
@@ -401,6 +401,10 @@ export const settingsDict = {
'settings.common.actions.cancel': '取消',
'settings.common.actions.create': '创建',
'settings.common.actions.delete': '删除',
'settings.openchamber.appLinks.title': '受信任的应用链接',
'settings.openchamber.appLinks.info': '此列表中的链接在本设备上打开时不再询问。其他应用链接在打开前始终需要确认。',
'settings.openchamber.appLinks.empty': '本设备上暂无受信任的应用链接。打开链接时选择“信任并打开”即可添加到这里。',
'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 链接',
'settings.common.actions.reset': '重置',
'settings.common.actions.rename': '重命名',
'settings.common.actions.duplicate': '复制',
@@ -1515,6 +1515,12 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.history.loadOlder': '加载更早的消息',
'chat.appLink.confirm.title': '要在其他应用中打开此链接吗?',
'chat.appLink.confirm.description': '此聊天链接使用 {scheme} 协议,将在其他应用中打开。',
'chat.appLink.confirm.descriptionPlain': '此聊天链接将在其他应用中打开。',
'chat.appLink.confirm.cancel': '取消',
'chat.appLink.confirm.open': '打开一次',
'chat.appLink.confirm.trustAndOpen': '信任并打开',
'chat.autoReview.title': '代码审查循环正在运行',
'chat.autoReview.status.waitingForReviewer': '等待审查者',
'chat.autoReview.status.waitingForImplementer': '等待实现者',
@@ -398,6 +398,10 @@ export const settingsDict = {
'settings.common.actions.cancel': '取消',
'settings.common.actions.create': '建立',
'settings.common.actions.delete': '刪除',
'settings.openchamber.appLinks.title': '受信任的應用程式連結',
'settings.openchamber.appLinks.info': '此清單中的連結在這台裝置上開啟時不再詢問。其他應用程式連結在開啟前一律需要確認。',
'settings.openchamber.appLinks.empty': '這台裝置上目前沒有受信任的應用程式連結。開啟連結時選擇「信任並開啟」即可加入這裡。',
'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 連結',
'settings.common.actions.reset': '重設',
'settings.common.actions.rename': '重新命名',
'settings.common.actions.duplicate': '複製',
@@ -1525,6 +1525,12 @@ export const dict: Record<I18nKey, string> = {
'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable',
'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow',
'chat.history.loadOlder': '載入更早的訊息',
'chat.appLink.confirm.title': '要在其他應用程式中開啟此連結嗎?',
'chat.appLink.confirm.description': '此聊天連結使用 {scheme} 通訊協定,將在其他應用程式中開啟。',
'chat.appLink.confirm.descriptionPlain': '此聊天連結將在其他應用程式中開啟。',
'chat.appLink.confirm.cancel': '取消',
'chat.appLink.confirm.open': '開啟一次',
'chat.appLink.confirm.trustAndOpen': '信任並開啟',
'chat.autoReview.title': '程式碼審查循環執行中',
'chat.autoReview.status.waitingForReviewer': '等待審查者',
'chat.autoReview.status.waitingForImplementer': '等待實作者',
+7 -1
View File
@@ -50,7 +50,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.weekStartsOn',
keywords: ['calendar', 'monday', 'sunday'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'appearance.light-theme',
@@ -177,6 +176,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint',
keywords: ['telemetry', 'analytics'],
},
{
id: 'general.app-links',
page: 'general',
titleKey: 'settings.openchamber.appLinks.title',
descriptionKey: 'settings.openchamber.appLinks.info',
keywords: ['security', 'app link', 'deep link', 'scheme', 'protocol', 'obsidian', 'notion'],
},
{
id: 'chat.render-mode',
page: 'chat',
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test';
import { getUrlScheme, isAppLinkUrl } from '@/lib/url';
describe('getUrlScheme', () => {
test('extracts the lowercased scheme', () => {
expect(getUrlScheme('Obsidian://open?vault=X')).toBe('obsidian');
expect(getUrlScheme('https://example.test')).toBe('https');
});
test('returns null for unparseable values', () => {
expect(getUrlScheme('')).toBeNull();
expect(getUrlScheme('not a url')).toBeNull();
});
});
describe('isAppLinkUrl', () => {
test('accepts custom application schemes', () => {
expect(isAppLinkUrl('obsidian://open?vault=Notebook&file=a%20b')).toBe(true);
expect(isAppLinkUrl('vscode://file/path/to/file.ts')).toBe(true);
expect(isAppLinkUrl('linear://issue/ABC-1')).toBe(true);
expect(isAppLinkUrl('notion://note/xyz')).toBe(true);
expect(isAppLinkUrl('slack://channel?id=C123')).toBe(true);
});
test('rejects browser and communication schemes', () => {
expect(isAppLinkUrl('https://example.test')).toBe(false);
expect(isAppLinkUrl('http://example.test')).toBe(false);
expect(isAppLinkUrl('mailto:user@example.test')).toBe(false);
expect(isAppLinkUrl('tel:+1234567890')).toBe(false);
expect(isAppLinkUrl('sms:+1234567890')).toBe(false);
expect(isAppLinkUrl('webcal://example.test/cal.ics')).toBe(false);
});
test('rejects dangerous and internal schemes', () => {
expect(isAppLinkUrl('javascript:alert(1)')).toBe(false);
expect(isAppLinkUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe(false);
expect(isAppLinkUrl('vbscript:msgbox(1)')).toBe(false);
expect(isAppLinkUrl('blob:https://example.test/uuid')).toBe(false);
expect(isAppLinkUrl('about:blank')).toBe(false);
expect(isAppLinkUrl('file:///etc/passwd')).toBe(false);
expect(isAppLinkUrl('ws://localhost:8080')).toBe(false);
expect(isAppLinkUrl('ftp://files.example.test')).toBe(false);
expect(isAppLinkUrl('intent://scan/#Intent;scheme=zxing;end')).toBe(false);
expect(isAppLinkUrl('chrome://settings')).toBe(false);
expect(isAppLinkUrl('devtools://devtools/bundled/inspector.html')).toBe(false);
expect(isAppLinkUrl('ms-msdt:/id%20PCWDiagnostic')).toBe(false);
expect(isAppLinkUrl('search-ms:query=report')).toBe(false);
expect(isAppLinkUrl('shell:AppsFolder')).toBe(false);
});
test('rejects OpenChamber and Capacitor self-deep-links', () => {
expect(isAppLinkUrl('openchamber://connect?host=x')).toBe(false);
expect(isAppLinkUrl('openchamber-ui://app/index.html')).toBe(false);
expect(isAppLinkUrl('capacitor://localhost/index.html')).toBe(false);
});
test('rejects malformed input', () => {
expect(isAppLinkUrl('')).toBe(false);
expect(isAppLinkUrl('random text')).toBe(false);
});
});
+63 -5
View File
@@ -20,6 +20,61 @@ export const isExternalHttpUrl = (url: string): boolean => {
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
};
/** Lowercased URL scheme without the trailing colon, or null when unparseable. */
export const getUrlScheme = (url: string): string | null => {
const parsed = parseUrlSafely(url.trim());
if (!parsed) {
return null;
}
return parsed.protocol.replace(/:$/, '').toLowerCase();
};
/**
* Schemes the browser or OS communication apps already handle natively
* (mailto:, tel:, sms:, ...). They are not application deep links.
*/
const BROWSER_HANDLED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'sms', 'callto', 'cid', 'xmpp', 'irc', 'news', 'nntp', 'feed', 'webcal']);
/**
* Schemes that must never be preserved or opened from rendered chat content.
*/
const BLOCKED_APP_LINK_SCHEMES = new Set([
// Scriptable or web-content schemes
'javascript', 'data', 'vbscript', 'blob', 'filesystem', 'about',
// WebView/Electron internal schemes
'chrome', 'chrome-extension', 'devtools', 'moz-extension', 'ms-browser-extension',
// Local files flow through the dedicated file-link handling
'file',
// Network protocols that are not application links
'ws', 'wss', 'ftp', 'ftps',
// Android intent URIs can launch arbitrary components with extras
'intent',
// Historically abused Windows handlers can invoke diagnostic, shell, or
// file-search flows that must not be offered from untrusted chat content.
'ms-msdt', 'search-ms', 'shell',
// OpenChamber's own schemes must not be re-launched from chat content
'openchamber', 'openchamber-ui', 'capacitor',
]);
const APP_LINK_SCHEME_RE = /^[a-z][a-z0-9+.-]{1,31}$/;
/**
* True for custom application deep links such as `obsidian://`, `linear://`,
* or `vscode://`. Browser-handled and dangerous/internal schemes are excluded,
* so a true result means the link may be offered to the user behind a
* confirmation the first time its scheme appears.
*/
export const isAppLinkUrl = (url: string): boolean => {
const scheme = getUrlScheme(url);
if (!scheme) {
return false;
}
if (BROWSER_HANDLED_SCHEMES.has(scheme) || BLOCKED_APP_LINK_SCHEMES.has(scheme)) {
return false;
}
return APP_LINK_SCHEME_RE.test(scheme);
};
export const getExternalFaviconUrl = (url: string): string | null => {
const parsed = parseUrlSafely(url.trim());
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
@@ -88,7 +143,7 @@ export const extractLoopbackUrls = (text: string): string[] => {
* @param url - The URL to open
* @returns Promise<boolean> - true if the URL was opened successfully
*/
export const openExternalUrl = async (url: string): Promise<boolean> => {
const openValidatedExternalUrl = async (url: string): Promise<boolean> => {
if (typeof window === 'undefined') {
return false;
}
@@ -103,10 +158,6 @@ export const openExternalUrl = async (url: string): Promise<boolean> => {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const normalizedTarget = parsed.toString();
const runtimeApis = getRegisteredRuntimeAPIs();
@@ -136,3 +187,10 @@ export const openExternalUrl = async (url: string): Promise<boolean> => {
return false;
}
};
export const openExternalUrl = (url: string): Promise<boolean> =>
isExternalHttpUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false);
/** Opens a classified app link after the caller has completed confirmation. */
export const openConfirmedAppLinkUrl = (url: string): Promise<boolean> =>
isAppLinkUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false);
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useAppLinkTrustStore, MAX_TRUSTED_SCHEMES } from './appLinkTrustStore';
describe('app link trust store', () => {
beforeEach(() => {
useAppLinkTrustStore.setState({ trustedSchemes: [] });
});
test('trusts a scheme with case and whitespace normalization', () => {
const store = useAppLinkTrustStore.getState();
store.trustScheme(' Obsidian ');
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian']);
expect(useAppLinkTrustStore.getState().isSchemeTrusted('OBSIDIAN')).toBe(true);
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(false);
});
test('re-trusting moves the scheme to the front without duplicates', () => {
const store = useAppLinkTrustStore.getState();
store.trustScheme('obsidian');
store.trustScheme('linear');
store.trustScheme('obsidian');
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian', 'linear']);
});
test('removes a trusted scheme', () => {
const store = useAppLinkTrustStore.getState();
store.trustScheme('obsidian');
store.trustScheme('linear');
useAppLinkTrustStore.getState().removeTrustedScheme('obsidian');
expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['linear']);
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(false);
});
test('caps the stored scheme list', () => {
const store = useAppLinkTrustStore.getState();
for (let index = 0; index < MAX_TRUSTED_SCHEMES + 5; index += 1) {
store.trustScheme(`scheme${index}`);
}
const schemes = useAppLinkTrustStore.getState().trustedSchemes;
expect(schemes).toHaveLength(MAX_TRUSTED_SCHEMES);
expect(schemes[0]).toBe(`scheme${MAX_TRUSTED_SCHEMES + 4}`);
});
});
@@ -0,0 +1,48 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage';
export const MAX_TRUSTED_SCHEMES = 64;
interface AppLinkTrustState {
/** Application deep-link schemes (obsidian, vscode, ...) the user chose to always allow. */
trustedSchemes: string[];
trustScheme: (scheme: string) => void;
removeTrustedScheme: (scheme: string) => void;
isSchemeTrusted: (scheme: string) => boolean;
}
const normalizeScheme = (scheme: string): string => scheme.trim().toLowerCase();
/**
* Per-device trust for application deep links rendered in chat. Security
* decisions do not roam, so this persists locally through the shared safe
* storage rather than server-synced settings.
*/
export const useAppLinkTrustStore = create<AppLinkTrustState>()(
persist(
(set, get) => ({
trustedSchemes: [],
trustScheme: (scheme) => {
const normalized = normalizeScheme(scheme);
if (!normalized) return;
set((state) => {
const next = [normalized, ...state.trustedSchemes.filter((entry) => entry !== normalized)];
return { trustedSchemes: next.slice(0, MAX_TRUSTED_SCHEMES) };
});
},
removeTrustedScheme: (scheme) => {
const normalized = normalizeScheme(scheme);
set((state) => ({ trustedSchemes: state.trustedSchemes.filter((entry) => entry !== normalized) }));
},
isSchemeTrusted: (scheme) => get().trustedSchemes.includes(normalizeScheme(scheme)),
}),
{
name: 'app-link-trust-store',
storage: createDeferredSafeJSONStorage(),
version: 1,
partialize: (state) => ({ trustedSchemes: state.trustedSchemes }),
},
),
);