Skip to content
Open
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
19 changes: 19 additions & 0 deletions packages/desktop/src/main/external-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test"
import { safeExternalUrl } from "./external-link"

describe("safeExternalUrl", () => {
test("allows web links", () => {
expect(safeExternalUrl("https://opencode.ai/docs")).toBe("https://opencode.ai/docs")
expect(safeExternalUrl("http://localhost:3000/callback")).toBe("http://localhost:3000/callback")
})

test("rejects executable and application protocols", () => {
expect(safeExternalUrl("file:///tmp/script.sh")).toBeUndefined()
expect(safeExternalUrl("ssh://attacker.example")).toBeUndefined()
expect(safeExternalUrl("custom-handler:payload")).toBeUndefined()
})

test("rejects malformed URLs", () => {
expect(safeExternalUrl("not a url")).toBeUndefined()
})
})
8 changes: 8 additions & 0 deletions packages/desktop/src/main/external-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const allowedProtocols = new Set(["https:", "http:"])

export function safeExternalUrl(value: string) {
if (!URL.canParse(value)) return
const url = new URL(value)
if (!allowedProtocols.has(url.protocol)) return
return url.toString()
}
5 changes: 4 additions & 1 deletion packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { safeExternalUrl } from "./external-link"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
Expand Down Expand Up @@ -178,7 +179,9 @@ export function registerIpcHandlers(deps: Deps) {
)

ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url)
const externalUrl = safeExternalUrl(url)
if (!externalUrl) return
void shell.openExternal(externalUrl)
})

ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
Expand Down
36 changes: 36 additions & 0 deletions packages/desktop/src/renderer/file-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { isFileHref, pathFromFileHref } from "./file-link"

describe("pathFromFileHref", () => {
test("parses unix file URLs", () => {
expect(pathFromFileHref("file:///Users/me/project/file.md")).toBe("/Users/me/project/file.md")
expect(pathFromFileHref("file:///tmp/opencode")).toBe("/tmp/opencode")
})

test("parses windows file URLs", () => {
expect(pathFromFileHref("file:///C:/Users/me/file.md")).toBe("C:/Users/me/file.md")
})

test("accepts bare absolute paths", () => {
expect(pathFromFileHref("/Users/me/project/file.md")).toBe("/Users/me/project/file.md")
expect(pathFromFileHref("C:\\Users\\me\\file.md")).toBe("C:/Users/me/file.md")
})

test("strips line and column suffixes", () => {
expect(pathFromFileHref("file:///Users/me/app.ts:12")).toBe("/Users/me/app.ts")
expect(pathFromFileHref("/Users/me/app.ts:12:4")).toBe("/Users/me/app.ts")
})

test("rejects web URLs", () => {
expect(pathFromFileHref("https://opencode.ai")).toBeUndefined()
expect(pathFromFileHref("http://localhost:3000")).toBeUndefined()
})
})

describe("isFileHref", () => {
test("detects file targets", () => {
expect(isFileHref("file:///tmp/x")).toBe(true)
expect(isFileHref("/tmp/x")).toBe(true)
expect(isFileHref("https://opencode.ai")).toBe(false)
})
})
40 changes: 40 additions & 0 deletions packages/desktop/src/renderer/file-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** Convert a file: URL (or absolute path href) into a filesystem path. */
export function pathFromFileHref(href: string): string | undefined {
const value = href.trim()
if (!value) return

if (value.startsWith("/") && !value.startsWith("//")) {
return stripLocation(value)
}

if (/^[a-zA-Z]:[\\/]/.test(value)) {
return stripLocation(value.replace(/\\/g, "/"))
}

if (!URL.canParse(value)) return
const url = new URL(value)
if (url.protocol !== "file:") return

let path = decodeURIComponent(url.pathname)
// Windows file URLs are `/C:/...`
if (/^\/[a-zA-Z]:\//.test(path)) path = path.slice(1)
// UNC paths: file://server/share → pathname `/share`, host is server
if (url.hostname && url.hostname !== "localhost") {
path = `//${url.hostname}${path}`
}
return stripLocation(path) || undefined
}

function stripLocation(path: string) {
// Support `path:line` and `path:line:col` suffixes common in agent output.
// Keep Windows drive letters (`C:...`).
return path.replace(/:(\d+)(:\d+)?$/, (match, _line, _col, offset, full) => {
const before = full.slice(0, offset)
if (!before || /^[a-zA-Z]$/.test(before)) return match
return ""
})
}

export function isFileHref(href: string) {
return pathFromFileHref(href) !== undefined && (/^file:/i.test(href.trim()) || href.trim().startsWith("/") || /^[a-zA-Z]:[\\/]/.test(href.trim()))
}
22 changes: 19 additions & 3 deletions packages/desktop/src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import { windowFullscreen } from "./window-fullscreen"
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
import { pathFromFileHref } from "./file-link"
import "./styles.css"
import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme/context"
Expand Down Expand Up @@ -360,10 +361,25 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {

function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
e.preventDefault()
platform.openLink(link.href)
if (!link?.href) return
e.preventDefault()

// Prefer the raw attribute so file: links aren't rewritten against oc://renderer.
const href = link.getAttribute("href") || link.href
const filePath = pathFromFileHref(href)
if (filePath) {
const reveal = e.metaKey || e.ctrlKey || e.altKey || e.button === 1
if (reveal && platform.revealPath) {
void platform.revealPath(filePath)
return
}
if (platform.openPath) {
void platform.openPath(filePath)
return
}
}

platform.openLink(link.href)
}

function Inner() {
Expand Down
6 changes: 6 additions & 0 deletions packages/session-ui/src/components/markdown-cache.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { checksum } from "@opencode-ai/core/util/encode"
import DOMPurify from "dompurify"
import { desktopAllowedUriRegexp, isDesktopRenderer } from "./markdown-desktop"
import { project } from "./markdown-stream"

export type MarkdownCacheEntry = {
Expand Down Expand Up @@ -34,6 +35,11 @@ if (typeof window !== "undefined" && DOMPurify.isSupported) {

export function sanitizeMarkdown(html: string) {
if (!DOMPurify.isSupported) return ""
// Desktop: keep file: hrefs so chat path links stay clickable. Web keeps the
// default scheme allowlist (browsers block file: navigation anyway).
if (isDesktopRenderer()) {
return DOMPurify.sanitize(html, { ...config, ALLOWED_URI_REGEXP: desktopAllowedUriRegexp })
}
return DOMPurify.sanitize(html, config)
}

Expand Down
38 changes: 38 additions & 0 deletions packages/session-ui/src/components/markdown-desktop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { absolutePathHref, desktopAllowedUriRegexp } from "./markdown-desktop"

describe("absolutePathHref", () => {
test("converts unix absolute paths", () => {
expect(absolutePathHref("/Users/me/project/file.md")).toBe("file:///Users/me/project/file.md")
expect(absolutePathHref("`/tmp/x`")).toBeUndefined()
})

test("converts windows absolute paths", () => {
expect(absolutePathHref("C:\\Users\\me\\file.md")).toBe("file:///C:/Users/me/file.md")
expect(absolutePathHref("D:/repo/app.ts")).toBe("file:///D:/repo/app.ts")
})

test("passes through file URLs", () => {
expect(absolutePathHref("file:///tmp/opencode")).toBe("file:///tmp/opencode")
})

test("strips trailing punctuation and line numbers", () => {
expect(absolutePathHref("/Users/me/app.ts.")).toBe("file:///Users/me/app.ts")
expect(absolutePathHref("/Users/me/app.ts:12")).toBe("file:///Users/me/app.ts")
expect(absolutePathHref("/Users/me/app.ts:12:4")).toBe("file:///Users/me/app.ts")
})

test("rejects relative paths and web urls", () => {
expect(absolutePathHref("src/app.ts")).toBeUndefined()
expect(absolutePathHref("https://opencode.ai")).toBeUndefined()
expect(absolutePathHref("/")).toBeUndefined()
})
})

describe("desktopAllowedUriRegexp", () => {
test("allows file and web schemes", () => {
expect(desktopAllowedUriRegexp.test("file:///tmp/x")).toBe(true)
expect(desktopAllowedUriRegexp.test("https://opencode.ai")).toBe(true)
expect(desktopAllowedUriRegexp.test("http://localhost")).toBe(true)
})
})
57 changes: 57 additions & 0 deletions packages/session-ui/src/components/markdown-desktop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** True when markdown is rendered inside the OpenCode desktop Electron shell. */
export function isDesktopRenderer() {
if (typeof window === "undefined") return false
if (window.location.protocol === "oc:") return true
return typeof (window as Window & { api?: { openPath?: unknown } }).api?.openPath === "function"
}

/** DOMPurify default plus `file:` — only enable on desktop; browsers block file: anyway. */
export const desktopAllowedUriRegexp =
/^(?:(?:(?:f|ht)tps?|file|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i

function stripLineColumn(path: string) {
// `path:line` / `path:line:col` — keep Windows drive letters (`C:...`)
return path.replace(/:(\d+)(:\d+)?$/, (match, _line, _col, offset, full) => {
const before = full.slice(0, offset)
if (!before || /^[a-zA-Z]$/.test(before)) return match
return ""
})
}

function toFileHref(path: string) {
if (path.startsWith("/")) return `file://${encodeURI(path).replace(/#/g, "%23")}`
return `file:///${encodeURI(path).replace(/#/g, "%23")}`
}

/** Build a file: href from bare absolute path text, or return undefined. */
export function absolutePathHref(text: string): string | undefined {
const href = text.trim().replace(/[),.;!?]+$/, "")
if (!href || /\s/.test(href)) return

// Already a file URL
if (/^file:\/\//i.test(href)) {
try {
const url = new URL(href)
let path = decodeURIComponent(url.pathname)
if (/^\/[a-zA-Z]:\//.test(path)) path = path.slice(1)
path = stripLineColumn(path)
if (!path || path === "/") return
return toFileHref(path)
} catch {
return
}
}

// Unix absolute
if (href.startsWith("/") && href.length > 1) {
const path = stripLineColumn(href)
if (path === "/") return
return toFileHref(path)
}

// Windows absolute
if (/^[a-zA-Z]:[\\/]/.test(href)) {
const path = stripLineColumn(href.replace(/\\/g, "/"))
return toFileHref(path)
}
}
16 changes: 10 additions & 6 deletions packages/session-ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { absolutePathHref, isDesktopRenderer } from "./markdown-desktop"
import { inlineCodeKind } from "./markdown-inline-code-kind"

type RenderedBlock =
Expand Down Expand Up @@ -99,13 +100,16 @@ const urlPattern = /^https?:\/\/[^\s<>()`"']+$/

function codeUrl(text: string) {
const href = text.trim().replace(/[),.;!?]+$/, "")
if (!urlPattern.test(href)) return
try {
const url = new URL(href)
return url.toString()
} catch {
return
if (urlPattern.test(href)) {
try {
return new URL(href).toString()
} catch {
return
}
}
// Desktop only: turn absolute paths / file: URLs into clickable file links.
if (!isDesktopRenderer()) return
return absolutePathHref(href)
}

function createCopyButton(labels: CopyLabels) {
Expand Down
Loading