Skip to content

Commit e71ec43

Browse files
committed
webview: never render base64; render backend-saved image URIs; allow globalStorage URIs; fix 401
1 parent f934363 commit e71ec43

File tree

12 files changed

+301
-66
lines changed

12 files changed

+301
-66
lines changed

src/activate/registerCommands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,8 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
256256
const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
257257
enableScripts: true,
258258
retainContextWhenHidden: true,
259-
localResourceRoots: [context.extensionUri],
259+
// Allow webview to load images saved under globalStorageUri
260+
localResourceRoots: [context.extensionUri, context.globalStorageUri],
260261
})
261262

262263
// Save as tab type panel.

src/core/webview/ClineProvider.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,7 @@ export class ClineProvider
728728
})
729729

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

733733
// Add workspace folders to allow access to workspace files
734734
if (vscode.workspace.workspaceFolders) {
@@ -1008,7 +1008,7 @@ export class ClineProvider
10081008
"default-src 'none'",
10091009
`font-src ${webview.cspSource} data:`,
10101010
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
1011-
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`,
1011+
`img-src ${webview.cspSource} https://*.vscode-cdn.net https://storage.googleapis.com https://img.clerk.com data:`,
10121012
`media-src ${webview.cspSource}`,
10131013
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
10141014
`connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
@@ -1093,7 +1093,7 @@ export class ClineProvider
10931093
<meta charset="utf-8">
10941094
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
10951095
<meta name="theme-color" content="#000000">
1096-
<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;">
1096+
<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;">
10971097
<link rel="stylesheet" type="text/css" href="${stylesUri}">
10981098
<link href="${codiconsUri}" rel="stylesheet" />
10991099
<script nonce="${nonce}">
@@ -2720,27 +2720,35 @@ export class ClineProvider
27202720
*/
27212721
public convertToWebviewUri(filePath: string): string {
27222722
try {
2723-
const fileUri = vscode.Uri.file(filePath)
2724-
2725-
// Check if we have a webview available
2723+
// If a webview is available, generate a URI relative to an allowed localResourceRoot when possible.
27262724
if (this.view?.webview) {
2725+
let fileUri: vscode.Uri
2726+
2727+
// Prefer mapping under globalStorageUri to guarantee allow-list match for localResourceRoots
2728+
const gsRoot = this.contextProxy?.globalStorageUri?.fsPath
2729+
if (gsRoot && filePath.startsWith(gsRoot)) {
2730+
// Build a URI under the globalStorage root using joinPath
2731+
const rel = path.relative(gsRoot, filePath)
2732+
const segments = rel.split(path.sep).filter(Boolean)
2733+
fileUri = vscode.Uri.joinPath(this.contextProxy.globalStorageUri, ...segments)
2734+
} else {
2735+
// Fallback to direct file URI
2736+
fileUri = vscode.Uri.file(filePath)
2737+
}
2738+
27272739
const webviewUri = this.view.webview.asWebviewUri(fileUri)
27282740
return webviewUri.toString()
27292741
}
27302742

2731-
// Specific error for no webview available
2732-
const error = new Error("No webview available for URI conversion")
2733-
console.error(error.message)
2734-
// Fallback to file URI if no webview available
2735-
return fileUri.toString()
2743+
// No webview available; fallback to file URI
2744+
console.error("No webview available for URI conversion")
2745+
return vscode.Uri.file(filePath).toString()
27362746
} catch (error) {
2737-
// More specific error handling
27382747
if (error instanceof TypeError) {
27392748
console.error("Invalid file path provided for URI conversion:", error)
27402749
} else {
27412750
console.error("Failed to convert to webview URI:", error)
27422751
}
2743-
// Return file URI as fallback
27442752
return vscode.Uri.file(filePath).toString()
27452753
}
27462754
}

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ describe("ClineProvider", () => {
456456

457457
expect(mockWebviewView.webview.options).toEqual({
458458
enableScripts: true,
459-
localResourceRoots: [mockContext.extensionUri],
459+
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
460460
})
461461

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

476476
expect(mockWebviewView.webview.options).toEqual({
477477
enableScripts: true,
478-
localResourceRoots: [mockContext.extensionUri],
478+
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
479479
})
480480

481481
expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")

src/core/webview/webviewMessageHandler.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ import { checkExistKey } from "../../shared/checkExistApiConfig"
3636
import { experimentDefault } from "../../shared/experiments"
3737
import { Terminal } from "../../integrations/terminal/Terminal"
3838
import { openFile } from "../../integrations/misc/open-file"
39-
import { openImage, saveImage } from "../../integrations/misc/image-handler"
39+
import {
40+
openImage,
41+
saveImage,
42+
savePastedImageToTemp,
43+
importImageToGlobalStorage,
44+
} from "../../integrations/misc/image-handler"
4045
import { selectImages } from "../../integrations/misc/process-images"
4146
import { getTheme } from "../../integrations/theme/getTheme"
4247
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
@@ -614,13 +619,24 @@ export const webviewMessageHandler = async (
614619
await provider.postStateToWebview()
615620
break
616621
case "selectImages":
617-
const images = await selectImages()
618-
await provider.postMessageToWebview({
619-
type: "selectedImages",
620-
images,
621-
context: message.context,
622-
messageTs: message.messageTs,
623-
})
622+
// Copy selected images into global storage and return webview-safe URIs
623+
{
624+
const pickedPaths = await selectImages()
625+
const results = await Promise.all(
626+
(pickedPaths || []).map((p) => importImageToGlobalStorage(p, provider)),
627+
)
628+
// Ensure URIs are derived via current webview context if available
629+
const images = results
630+
.filter((r): r is { imagePath: string; imageUri: string } => !!r)
631+
.map((r) => provider?.convertToWebviewUri?.(r.imagePath) ?? r.imageUri)
632+
633+
await provider.postMessageToWebview({
634+
type: "selectedImages",
635+
images,
636+
context: message.context,
637+
messageTs: message.messageTs,
638+
})
639+
}
624640
break
625641
case "exportCurrentTask":
626642
const currentTaskId = provider.getCurrentTask()?.taskId
@@ -3035,5 +3051,26 @@ export const webviewMessageHandler = async (
30353051
})
30363052
break
30373053
}
3054+
case "savePastedImage": {
3055+
// Save pasted image to temporary file and return path and URI
3056+
if (message.dataUri) {
3057+
const result = await savePastedImageToTemp(message.dataUri, provider)
3058+
if (result) {
3059+
await provider.postMessageToWebview({
3060+
type: "pastedImageSaved",
3061+
imagePath: result.imagePath,
3062+
imageUri: result.imageUri,
3063+
requestId: message.requestId,
3064+
})
3065+
} else {
3066+
await provider.postMessageToWebview({
3067+
type: "pastedImageSaved",
3068+
error: "Failed to save pasted image",
3069+
requestId: message.requestId,
3070+
})
3071+
}
3072+
}
3073+
break
3074+
}
30383075
}
30393076
}

src/integrations/misc/image-handler.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as path from "path"
22
import * as os from "os"
33
import * as vscode from "vscode"
4+
import * as fs from "fs/promises"
45
import { getWorkspacePath } from "../../utils/path"
56
import { t } from "../../i18n"
67

@@ -42,7 +43,6 @@ export async function openImage(dataUriOrPath: string, options?: { values?: { ac
4243
return
4344
}
4445

45-
// Handle data URI (existing logic)
4646
const matches = dataUriOrPath.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
4747
if (!matches) {
4848
vscode.window.showErrorMessage(t("common:errors.invalid_data_uri"))
@@ -90,6 +90,108 @@ export async function openImage(dataUriOrPath: string, options?: { values?: { ac
9090
}
9191
}
9292

93+
/**
94+
* Save a pasted/dropped image to global storage and return its path and webview URI
95+
* This uses VSCode's global storage for persistence across sessions
96+
*/
97+
export async function importImageToGlobalStorage(
98+
imagePath: string,
99+
provider?: any,
100+
): Promise<{ imagePath: string; imageUri: string } | null> {
101+
try {
102+
// Determine storage directory (global storage preferred, fallback to temp)
103+
let imagesDir: string
104+
if (provider?.contextProxy?.globalStorageUri) {
105+
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
106+
const taskId = provider.getCurrentTask?.()?.taskId
107+
imagesDir = taskId
108+
? path.join(globalStoragePath, "user-images", `task-${taskId}`)
109+
: path.join(globalStoragePath, "user-images", "general")
110+
} else {
111+
console.warn("Provider context not available, falling back to temp directory")
112+
imagesDir = path.join(os.tmpdir(), "roo-user-images")
113+
}
114+
115+
await fs.mkdir(imagesDir, { recursive: true })
116+
117+
// Preserve original extension if possible
118+
const ext = path.extname(imagePath) || ".png"
119+
const timestamp = Date.now()
120+
const randomId = Math.random().toString(36).substring(2, 8)
121+
const destFileName = `imported_image_${timestamp}_${randomId}${ext}`
122+
const destPath = path.join(imagesDir, destFileName)
123+
124+
// Copy the original image into global storage
125+
await fs.copyFile(imagePath, destPath)
126+
127+
// Convert to webview URI
128+
let webviewUri = provider?.convertToWebviewUri?.(destPath) ?? vscode.Uri.file(destPath).toString()
129+
130+
return { imagePath: destPath, imageUri: webviewUri }
131+
} catch (error) {
132+
console.error("Failed to import image into global storage:", error)
133+
return null
134+
}
135+
}
136+
137+
export async function savePastedImageToTemp(
138+
dataUri: string,
139+
provider?: any,
140+
): Promise<{ imagePath: string; imageUri: string } | null> {
141+
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
142+
if (!matches) {
143+
return null
144+
}
145+
146+
const [, format, base64Data] = matches
147+
const imageBuffer = Buffer.from(base64Data, "base64")
148+
149+
// Determine storage directory
150+
let imagesDir: string
151+
152+
// Use global storage if provider context is available
153+
if (provider?.contextProxy?.globalStorageUri) {
154+
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
155+
156+
// Organize by task ID if available
157+
const taskId = provider.getCurrentTask?.()?.taskId
158+
if (taskId) {
159+
imagesDir = path.join(globalStoragePath, "pasted-images", `task-${taskId}`)
160+
} else {
161+
// Fallback to general pasted-images directory
162+
imagesDir = path.join(globalStoragePath, "pasted-images", "general")
163+
}
164+
} else {
165+
// Fallback to temp directory if provider context is not available
166+
console.warn("Provider context not available, falling back to temp directory")
167+
imagesDir = path.join(os.tmpdir(), "roo-pasted-images")
168+
}
169+
170+
// Create directory if it doesn't exist
171+
await fs.mkdir(imagesDir, { recursive: true })
172+
173+
// Generate a unique filename
174+
const timestamp = Date.now()
175+
const randomId = Math.random().toString(36).substring(2, 8)
176+
const fileName = `pasted_image_${timestamp}_${randomId}.${format}`
177+
const imagePath = path.join(imagesDir, fileName)
178+
179+
try {
180+
// Write the image to the file
181+
await fs.writeFile(imagePath, imageBuffer)
182+
183+
// Convert to webview URI if provider is available
184+
let imageUri = provider?.convertToWebviewUri?.(imagePath) ?? vscode.Uri.file(imagePath).toString()
185+
186+
// Do not append custom query params to VS Code webview URIs (can break auth token and cause 401)
187+
188+
return { imagePath, imageUri }
189+
} catch (error) {
190+
console.error("Failed to save pasted image:", error)
191+
return null
192+
}
193+
}
194+
93195
export async function saveImage(dataUri: string) {
94196
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
95197
if (!matches) {
Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as vscode from "vscode"
2-
import fs from "fs/promises"
3-
import * as path from "path"
42

3+
/**
4+
* Open a file picker to select images, returning absolute file system paths.
5+
* Rendering-friendly webview URIs will be produced in the webviewMessageHandler.
6+
*/
57
export async function selectImages(): Promise<string[]> {
68
const options: vscode.OpenDialogOptions = {
79
canSelectMany: true,
@@ -12,34 +14,10 @@ export async function selectImages(): Promise<string[]> {
1214
}
1315

1416
const fileUris = await vscode.window.showOpenDialog(options)
15-
1617
if (!fileUris || fileUris.length === 0) {
1718
return []
1819
}
1920

20-
return await Promise.all(
21-
fileUris.map(async (uri) => {
22-
const imagePath = uri.fsPath
23-
const buffer = await fs.readFile(imagePath)
24-
const base64 = buffer.toString("base64")
25-
const mimeType = getMimeType(imagePath)
26-
const dataUrl = `data:${mimeType};base64,${base64}`
27-
return dataUrl
28-
}),
29-
)
30-
}
31-
32-
function getMimeType(filePath: string): string {
33-
const ext = path.extname(filePath).toLowerCase()
34-
switch (ext) {
35-
case ".png":
36-
return "image/png"
37-
case ".jpeg":
38-
case ".jpg":
39-
return "image/jpeg"
40-
case ".webp":
41-
return "image/webp"
42-
default:
43-
throw new Error(`Unsupported file type: ${ext}`)
44-
}
21+
// Return fs paths only; do not read/encode files here.
22+
return fileUris.map((uri) => uri.fsPath)
4523
}

src/shared/ExtensionMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export interface ExtensionMessage {
124124
| "commands"
125125
| "insertTextIntoTextarea"
126126
| "dismissedUpsells"
127+
| "pastedImageSaved"
127128
text?: string
128129
payload?: any // Add a generic payload for now, can refine later
129130
action?:
@@ -201,6 +202,8 @@ export interface ExtensionMessage {
201202
commands?: Command[]
202203
queuedMessages?: QueuedMessage[]
203204
list?: string[] // For dismissedUpsells
205+
imagePath?: string // For pastedImageSaved
206+
imageUri?: string // For pastedImageSaved
204207
}
205208

206209
export type ExtensionState = Pick<

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export interface WebviewMessage {
225225
| "editQueuedMessage"
226226
| "dismissUpsell"
227227
| "getDismissedUpsells"
228+
| "savePastedImage"
228229
text?: string
229230
editedMessageContent?: string
230231
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

0 commit comments

Comments
 (0)