Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,41 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens known app deep links in the OS handler", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const urls = [
"linear://linear.app/example/issue/EXAMPLE-123",
"slack://channel?team=T123&id=C123",
"notion://www.notion.so/example-page",
"obsidian://open?vault=Example&file=Notes",
];
const results = yield* Effect.forEach(urls, electronShell.openExternal);

assert.deepEqual(results, [true, true, true, true]);
assert.deepEqual(
openExternalMock.mock.calls,
urls.map((url) => [url]),
);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open executable or unknown schemes", () =>
Effect.gen(function* () {
const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.all([
electronShell.openExternal("javascript:alert(1)"),
electronShell.openExternal("data:text/html,<script>alert(1)</script>"),
electronShell.openExternal("unknown://example.com"),
]);

assert.deepEqual(results, [false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("copies text to the system clipboard", () =>
Effect.gen(function* () {
writeTextMock.mockResolvedValue(undefined);
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as Electron from "electron";
import { isExternalAppLink } from "@t3tools/shared/externalAppLinks";

// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,
// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
// scheme stays blocked.
// Known apps and remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,
// `zed://ssh/<host>/<path>`) must reach the OS handler; other non-web schemes stay blocked.
const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
const REMOTE_EDITOR_PROTOCOLS = new Set(
REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
Expand Down Expand Up @@ -42,7 +42,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {

try {
const url = new URL(rawUrl);
return SAFE_WEB_PROTOCOLS.has(url.protocol) || isRemoteEditorUrl(url)
return SAFE_WEB_PROTOCOLS.has(url.protocol) ||
isExternalAppLink(url.href) ||
isRemoteEditorUrl(url)
? Option.some(url.href)
: Option.none();
} catch {
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
parseMarkdownFileLink,
} from "@t3tools/client-runtime/markdown-links";
import { videoMimeType } from "@t3tools/shared/video";
import { isExternalAppLink } from "@t3tools/shared/externalAppLinks";

import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";

Expand Down Expand Up @@ -293,7 +294,7 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese

return {
kind: "link",
href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,
href: /^(?:mailto|tel):/i.test(normalized) || isExternalAppLink(normalized) ? normalized : null,
};
}

Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/lib/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ describe("resolveMarkdownLinkIcon", () => {
});

describe("resolveMarkdownLinkPresentation", () => {
it.each([
"linear://linear.app/example/issue/EXAMPLE-123",
"slack://channel?team=T123&id=C123",
"notion://www.notion.so/example-page",
"obsidian://open?vault=Example&file=Notes",
])("preserves app deep link %s for the native URL handler", (href) => {
expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href });
});

it.each(["javascript:alert(1)", "data:text/html,test", "unknown://example.com"])(
"does not make unsupported scheme %s clickable",
(href) => {
expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href: null });
},
);

it("treats protocol-relative media as an external URL, not a filesystem path", () => {
expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({
kind: "external",
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,59 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
return button.props as ComponentProps<typeof Button>;
}

describe.each([
"linear://linear.app/example/issue/EXAMPLE-123",
"slack://channel?team=T123&id=C123",
"notion://www.notion.so/example-page",
"obsidian://open?vault=Example&file=Notes",
])("ChatMarkdown app deep link %s", (href) => {
it.each([true, false])(
"preserves app destinations with lowercase and mixed-case schemes with parseRawHtml=%s",
async (parseRawHtml) => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
const uppercaseHref = href.replace(/^[^:]+/, (scheme) => scheme.toUpperCase());
const mixedCaseHref = href.replace(/^./, (letter) => letter.toUpperCase());
const text = [
`[Open in app](${href})`,
"[Open][app]",
`[app]: ${uppercaseHref}`,
`<${mixedCaseHref}>`,
`[Uppercase](${uppercaseHref})`,
"[Unsafe](JAVASCRIPT:alert)",
"[Unknown](UNKNOWN://example.com)",
...(parseRawHtml
? [
`<a href="${mixedCaseHref}">HTML app link</a>`,
'<a href="JAVASCRIPT:alert">Unsafe HTML</a>',
]
: []),
].join("\n\n");
try {
await act(async () => {
renderer = create(
<ChatMarkdown cwd="/workspace/project" text={text} parseRawHtml={parseRawHtml} />,
);
});

const destinations = renderer!.root.findAllByType("a").map((link) => link.props.href);
expect(destinations.filter(Boolean)).toEqual([
href,
parseRawHtml ? href : uppercaseHref,
parseRawHtml ? href : mixedCaseHref,
parseRawHtml ? href : uppercaseHref,
...(parseRawHtml ? [href] : []),
]);
} finally {
await act(async () => {
renderer?.unmount();
});
vi.unstubAllGlobals();
}
},
);
});

describe("ChatMarkdown context references", () => {
it("renders text and image references through the chip renderer, with readable fallback", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
Expand Down
25 changes: 21 additions & 4 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
ThreadPullRequestKey,
} from "@t3tools/contracts";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import { EXTERNAL_APP_LINK_SCHEMES, isExternalAppLink } from "@t3tools/shared/externalAppLinks";
import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
import {
isAtomCommandInterrupted,
Expand Down Expand Up @@ -448,10 +449,19 @@ function markStandaloneImages(node: MarkdownImageHastNode) {
});
}

/** Carries authored image source metadata through the sanitizer to the image renderer. */
function rehypePreserveImageSourceMeta() {
/** Prepares app link schemes and authored image metadata for the sanitizer. */
function rehypePrepareMarkdownSources() {
return (tree: MarkdownImageHastNode) => {
const visit = (node: MarkdownImageHastNode) => {
const href = node.properties?.href;
if (node.tagName === "a" && typeof href === "string" && isExternalAppLink(href)) {
// The sanitizer matches protocols case-sensitively. Preserve the rest of the URL.
const colon = href.indexOf(":");
node.properties = {
...node.properties,
href: href.slice(0, colon).toLowerCase() + href.slice(colon),
};
}
const src = node.properties?.src;
const title = node.properties?.title;
if (node.type === "element" && node.tagName === "img") {
Expand Down Expand Up @@ -487,7 +497,13 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
},
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "t3-context"],
href: [
...(defaultSchema.protocols?.href ?? []),
"file",
"t3-citation",
"t3-context",
...EXTERNAL_APP_LINK_SCHEMES,
Comment thread
suevalov marked this conversation as resolved.
Resolved
],
src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],
},
} satisfies Parameters<typeof rehypeSanitize>[0];
Expand Down Expand Up @@ -515,7 +531,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [

const CHAT_MARKDOWN_REHYPE_PLUGINS = [
rehypeRaw,
rehypePreserveImageSourceMeta,
rehypePrepareMarkdownSources,
[rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;

Expand Down Expand Up @@ -2580,6 +2596,7 @@ function useChatMarkdownState({
if (parseAssistantCitationHref(href)) return href;
if (parseComposerContextHref(href)) return href;
if (isWindowsDrivePathHref(href)) return href;
if (isExternalAppLink(href)) return href;
return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
}, []);
// Re-emit highlighted content as markdown so copying out of the rendered
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
"./externalAppLinks": {
"types": "./src/externalAppLinks.ts",
"import": "./src/externalAppLinks.ts"
},
"./orchestrationV2ThreadError": {
"types": "./src/orchestrationV2ThreadError.ts",
"import": "./src/orchestrationV2ThreadError.ts"
Expand Down
30 changes: 30 additions & 0 deletions packages/shared/src/externalAppLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vite-plus/test";

import { isExternalAppLink } from "./externalAppLinks";

describe("isExternalAppLink", () => {
it.each([
"linear://linear.app/example/issue/EXAMPLE-123",
"slack://channel?team=T123&id=C123",
"notion://www.notion.so/example-page",
"obsidian://open?vault=Example&file=Notes",
"LINEAR://linear.app/example/issue/EXAMPLE-123",
])("recognizes known app protocol in %s", (href) => {
expect(isExternalAppLink(href)).toBe(true);
});

it.each([
"javascript:alert(1)",
"data:text/html,test",
"file:///tmp/example.txt",
"unknown://example.com",
"vscode://publisher.extension/command",
"https://example.com/linear://example",
"/linear://example",
"linear-not-an-app://example",
"linear",
"",
])("does not treat %s as a known app link", (href) => {
expect(isExternalAppLink(href)).toBe(false);
});
});
9 changes: 9 additions & 0 deletions packages/shared/src/externalAppLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** App protocols supported by markdown links and the desktop OS handoff. */
export const EXTERNAL_APP_LINK_SCHEMES = ["linear", "slack", "notion", "obsidian"] as const;

const protocols = new Set<string>(EXTERNAL_APP_LINK_SCHEMES.map((scheme) => `${scheme}:`));

export function isExternalAppLink(href: string): boolean {
const colon = href.indexOf(":");
return colon > 0 && protocols.has(href.slice(0, colon + 1).toLowerCase());
}
Loading