Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e71ec43
webview: never render base64; render backend-saved image URIs; allow …
daniel-lxs Sep 22, 2025
addd1bc
feat: enhance openImage function to handle vscode webview CDN URLs
daniel-lxs Sep 23, 2025
b10c874
feat: add clipboard copy functionality to openImage for file paths
daniel-lxs Sep 23, 2025
ab402bf
fix: improve vscode-cdn.net URL validation and add copy action check
daniel-lxs Sep 23, 2025
f34243e
Merge branch 'main' into feat/webview-image-uri
daniel-lxs Sep 25, 2025
e7531e5
fix: complete PR #8225 - add missing webview URI to base64 conversion
daniel-lxs Oct 27, 2025
32b7085
optimize: implement efficient approach for PR #8225 - store base64 di…
daniel-lxs Oct 27, 2025
7029f1d
security: fix polynomial regex and improve URL sanitization in imageD…
daniel-lxs Oct 27, 2025
a1c402e
security: harden URL parsing against ReDoS and injection attacks
daniel-lxs Oct 27, 2025
3d796de
security: fix host injection vulnerability in URL validation
daniel-lxs Oct 27, 2025
9dc853c
feat(images): persist base64 in backend messages; normalize URIs once…
daniel-lxs Oct 28, 2025
634ee67
fix(image-uris): resolve review-bot issues
daniel-lxs Oct 28, 2025
5d3f45b
fix(image-uris): broaden Unix path regex to include common Linux roots
daniel-lxs Oct 28, 2025
1c7700e
feat(images): enforce 10MB limit UI+backend; reuse original base64 vi…
daniel-lxs Oct 28, 2025
09d512b
chore(webview): add image utils for size estimation and 10MB limit en…
daniel-lxs Oct 28, 2025
c445cfe
fix(images): remove legacy file:// URI support and use centralized im…
daniel-lxs Oct 28, 2025
f3b31d7
test(images): add CDN URI conversion tests for normalizeImageRefsToDa…
daniel-lxs Oct 28, 2025
d56b74f
fix(tests): normalize expected path for CDN URI test in imageDataUrl.…
daniel-lxs Oct 28, 2025
17ee88c
fix: optimize image handling with dual-storage approach
daniel-lxs Nov 3, 2025
7ddfa4e
refactor: remove unnecessary caching from normalizeDataUrlsToFilePaths
daniel-lxs Nov 3, 2025
3ff9b21
refactor: remove unused normalizeDataUrlsToFilePaths function
daniel-lxs Nov 3, 2025
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
3 changes: 2 additions & 1 deletion src/activate/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
// Allow webview to load images saved under globalStorageUri
localResourceRoots: [context.extensionUri, context.globalStorageUri],
})

// Save as tab type panel.
Expand Down
34 changes: 21 additions & 13 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ export class ClineProvider
})

// Set up webview options with proper resource roots
const resourceRoots = [this.contextProxy.extensionUri]
const resourceRoots = [this.contextProxy.extensionUri, this.contextProxy.globalStorageUri]

// Add workspace folders to allow access to workspace files
if (vscode.workspace.workspaceFolders) {
Expand Down Expand Up @@ -1008,7 +1008,7 @@ export class ClineProvider
"default-src 'none'",
`font-src ${webview.cspSource} data:`,
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`,
`img-src ${webview.cspSource} https://*.vscode-cdn.net https://storage.googleapis.com https://img.clerk.com data:`,
`media-src ${webview.cspSource}`,
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
Expand Down Expand Up @@ -1093,7 +1093,7 @@ export class ClineProvider
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src ${webview.cspSource} https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://*.vscode-cdn.net https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src ${webview.cspSource} https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<script nonce="${nonce}">
Expand Down Expand Up @@ -2720,27 +2720,35 @@ export class ClineProvider
*/
public convertToWebviewUri(filePath: string): string {
try {
const fileUri = vscode.Uri.file(filePath)

// Check if we have a webview available
// If a webview is available, generate a URI relative to an allowed localResourceRoot when possible.
if (this.view?.webview) {
let fileUri: vscode.Uri

// Prefer mapping under globalStorageUri to guarantee allow-list match for localResourceRoots
const gsRoot = this.contextProxy?.globalStorageUri?.fsPath
if (gsRoot && filePath.startsWith(gsRoot)) {
// Build a URI under the globalStorage root using joinPath
const rel = path.relative(gsRoot, filePath)
const segments = rel.split(path.sep).filter(Boolean)
fileUri = vscode.Uri.joinPath(this.contextProxy.globalStorageUri, ...segments)
} else {
// Fallback to direct file URI
fileUri = vscode.Uri.file(filePath)
}

const webviewUri = this.view.webview.asWebviewUri(fileUri)
return webviewUri.toString()
}

// Specific error for no webview available
const error = new Error("No webview available for URI conversion")
console.error(error.message)
// Fallback to file URI if no webview available
return fileUri.toString()
// No webview available; fallback to file URI
console.error("No webview available for URI conversion")
return vscode.Uri.file(filePath).toString()
} catch (error) {
// More specific error handling
if (error instanceof TypeError) {
console.error("Invalid file path provided for URI conversion:", error)
} else {
console.error("Failed to convert to webview URI:", error)
}
// Return file URI as fallback
return vscode.Uri.file(filePath).toString()
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ describe("ClineProvider", () => {

expect(mockWebviewView.webview.options).toEqual({
enableScripts: true,
localResourceRoots: [mockContext.extensionUri],
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
})

expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")
Expand All @@ -475,7 +475,7 @@ describe("ClineProvider", () => {

expect(mockWebviewView.webview.options).toEqual({
enableScripts: true,
localResourceRoots: [mockContext.extensionUri],
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
})

expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")
Expand Down
53 changes: 45 additions & 8 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
import {
openImage,
saveImage,
savePastedImageToTemp,
importImageToGlobalStorage,
} from "../../integrations/misc/image-handler"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
Expand Down Expand Up @@ -614,13 +619,24 @@ export const webviewMessageHandler = async (
await provider.postStateToWebview()
break
case "selectImages":
const images = await selectImages()
await provider.postMessageToWebview({
type: "selectedImages",
images,
context: message.context,
messageTs: message.messageTs,
})
// Copy selected images into global storage and return webview-safe URIs
{
const pickedPaths = await selectImages()
const results = await Promise.all(
(pickedPaths || []).map((p) => importImageToGlobalStorage(p, provider)),
)
// Ensure URIs are derived via current webview context if available
const images = results
.filter((r): r is { imagePath: string; imageUri: string } => !!r)
.map((r) => provider?.convertToWebviewUri?.(r.imagePath) ?? r.imageUri)

await provider.postMessageToWebview({
type: "selectedImages",
images,
context: message.context,
messageTs: message.messageTs,
})
}
break
case "exportCurrentTask":
const currentTaskId = provider.getCurrentTask()?.taskId
Expand Down Expand Up @@ -3035,5 +3051,26 @@ export const webviewMessageHandler = async (
})
break
}
case "savePastedImage": {
// Save pasted image to temporary file and return path and URI
if (message.dataUri) {
const result = await savePastedImageToTemp(message.dataUri, provider)
if (result) {
await provider.postMessageToWebview({
type: "pastedImageSaved",
imagePath: result.imagePath,
imageUri: result.imageUri,
requestId: message.requestId,
})
} else {
await provider.postMessageToWebview({
type: "pastedImageSaved",
error: "Failed to save pasted image",
requestId: message.requestId,
})
}
}
break
}
}
}
76 changes: 76 additions & 0 deletions src/integrations/misc/__tests__/image-handler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
vi.mock("vscode", () => {
const executeCommand = vi.fn()
const writeText = vi.fn()
const showInformationMessage = vi.fn()
const showErrorMessage = vi.fn()
const file = vi.fn((p: string) => ({ fsPath: p, path: p, scheme: "file" }))
const parse = (input: string) => {
if (input.startsWith("https://") && input.includes("vscode-cdn.net")) {
const url = new URL(input)
return {
scheme: "https",
authority: url.host,
path: url.pathname,
fsPath: url.pathname,
with: vi.fn(),
}
}
if (input.startsWith("file://")) {
return {
scheme: "file",
authority: "",
path: input.substring("file://".length),
fsPath: input.substring("file://".length),
with: vi.fn(),
}
}
return {
scheme: "file",
authority: "",
path: input,
fsPath: input,
with: vi.fn(),
}
}
return {
commands: { executeCommand },
env: { clipboard: { writeText } },
window: { showInformationMessage, showErrorMessage },
Uri: { file, parse },
}
})

import * as vscode from "vscode"
import { openImage } from "../image-handler"

describe("openImage - vscode webview CDN url handling", () => {
const cdnUrlPosix = "https://file+.vscode-resource.vscode-cdn.net/file//Users/test/workspace/image.png"

beforeEach(() => {
vi.clearAllMocks()
})

test("opens image from vscode-cdn webview URL by stripping /file/ and normalizing", async () => {
await openImage(cdnUrlPosix)

// Should normalize to /Users/test/workspace/image.png and open it
expect((vscode.Uri.file as any).mock.calls.length).toBe(1)
const calledWithPath = (vscode.Uri.file as any).mock.calls[0][0]
expect(calledWithPath).toBe(require("path").normalize("/Users/test/workspace/image.png"))

expect(vscode.commands.executeCommand).toHaveBeenCalledTimes(1)
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
"vscode.open",
expect.objectContaining({ fsPath: calledWithPath }),
)
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
})

test("copy action writes normalized fs path to clipboard (no open)", async () => {
await openImage(cdnUrlPosix, { values: { action: "copy" } })

const expectedPath = require("path").normalize("/Users/test/workspace/image.png")
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith(expectedPath)
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
})
})
Loading
Loading