From 74a491fe2139fcf3ec55fe7c0eb8aebbba612cca Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Sun, 12 Jul 2026 02:16:13 +0530 Subject: [PATCH 1/2] fix(desktop): preserve clipboard image paths for path-based MCP tools Ported from upstream anomalyco/opencode#36051. --- packages/app/src/components/prompt-input.tsx | 1 + .../components/prompt-input/attachments.ts | 16 ++++++- packages/app/src/context/platform.tsx | 3 ++ packages/desktop/src/main/ipc.ts | 44 ++++++++++++++++--- packages/desktop/src/preload/index.ts | 1 + packages/desktop/src/preload/types.ts | 3 +- packages/desktop/src/renderer/index.tsx | 8 +++- packages/tui/src/clipboard.ts | 39 +++++++++++++--- packages/tui/src/component/prompt/index.tsx | 23 ++++++++-- packages/tui/src/context/clipboard.tsx | 2 +- 10 files changed, 123 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 379e5a29b078..e260c8a245e9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1281,6 +1281,7 @@ export const PromptInput: Component = (props) => { addPart, readClipboardImage: platform.readClipboardImage, getPathForFile: platform.getPathForFile, + persistTempImage: platform.persistTempImage, }) const fileAttachmentInput = () => ( diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 82b2d906a89f..b44a73477ea0 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -36,6 +36,7 @@ type PromptAttachmentsCoreInput = { warn?: () => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string + persistTempImage?: (buffer: ArrayBuffer, ext: string) => Promise } type PromptAttachmentsInput = { @@ -47,6 +48,7 @@ type PromptAttachmentsInput = { addPart: (part: ContentPart) => boolean readClipboardImage?: () => Promise getPathForFile?: (file: File) => string + persistTempImage?: (buffer: ArrayBuffer, ext: string) => Promise } export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { @@ -68,11 +70,23 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { const url = await dataUrl(file, mime) if (!url) return false + let sourcePath = input.getPathForFile?.(file) || undefined + // Screenshots and other in-memory clipboard images have no disk path, so + // persist them to a temp file that file-path-based tools (e.g. MCP image readers) can access. + if (!sourcePath && input.persistTempImage) { + sourcePath = await input.persistTempImage(await file.arrayBuffer(), mime.split("/")[1] ?? "png").catch( + (err) => { + console.warn("Failed to persist temp image for attachment:", err) + return undefined + }, + ) + } + const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, - sourcePath: input.getPathForFile?.(file) || undefined, + sourcePath, mime, dataUrl: url, } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index e59bec9aafd9..4294f254a524 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -109,6 +109,9 @@ type PlatformBase = { /** Read image from clipboard (desktop only) */ readClipboardImage?(): Promise + /** Persist an in-memory file (e.g. screenshot) to a temp file and return its path (desktop only) */ + persistTempImage?(buffer: ArrayBuffer, ext: string): Promise + /** Export collected diagnostic logs (desktop only) */ exportDebugLogs?(): Promise diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 073d288e5cf0..a5d734fc18f6 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,6 +1,9 @@ import { execFile } from "node:child_process" -import { stat } from "node:fs/promises" -import { basename } from "node:path" +import { randomUUID } from "node:crypto" +import { mkdirSync, rmSync } from "node:fs" +import { stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { basename, join } from "node:path" import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" @@ -42,6 +45,18 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { + const clipboardTempDir = join(tmpdir(), `opencode-clipboard-${randomUUID()}`) + try { + mkdirSync(clipboardTempDir, { recursive: true }) + } catch (err) { + console.error("Failed to create clipboard temp directory:", err) + } + app.once("will-quit", () => { + try { + rmSync(clipboardTempDir, { recursive: true, force: true }) + } catch {} + }) + const updaterSubscriptions = createUpdaterSubscriptions() app.once("will-quit", updaterSubscriptions.clear) @@ -184,12 +199,31 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("read-clipboard-image", () => { + ipcMain.handle("read-clipboard-image", async () => { const image = clipboard.readImage() if (image.isEmpty()) return null - const buffer = image.toPNG().buffer + const png = image.toPNG() const size = image.getSize() - return { buffer, width: size.width, height: size.height } + // Persist the clipboard image to a temp file so file-path-based tools + // (e.g. MCP image readers) can access screenshots that only exist in memory. + const path = join(clipboardTempDir, `${randomUUID()}.png`) + try { + await writeFile(path, png) + } catch (err) { + console.error("Failed to persist clipboard image:", err) + return { buffer: png.buffer, width: size.width, height: size.height } + } + return { buffer: png.buffer, width: size.width, height: size.height, path } + }) + + ipcMain.handle("persist-temp-image", async (_event, buffer: ArrayBuffer, ext: string) => { + const path = join(clipboardTempDir, `${randomUUID()}.${ext || "png"}`) + try { + await writeFile(path, Buffer.from(buffer)) + } catch { + throw new Error(`Failed to persist temp image to ${path}`) + } + return path }) ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 47a757a557d3..c302cb92268d 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -96,6 +96,7 @@ const api: ElectronAPI = { openLink: (url) => ipcRenderer.send("open-link", url), openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), + persistTempImage: (buffer, ext) => ipcRenderer.invoke("persist-temp-image", buffer, ext), showNotification: (title, body) => ipcRenderer.send("show-notification", title, body), getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), setWindowFocus: () => ipcRenderer.invoke("set-window-focus"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index b57ac83e7f9e..55d9755f2f4a 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -86,7 +86,8 @@ export type ElectronAPI = { saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise openLink: (url: string) => void openPath: (path: string, app?: string) => Promise - readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> + readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number; path?: string } | null> + persistTempImage: (buffer: ArrayBuffer, ext: string) => Promise showNotification: (title: string, body?: string) => void getWindowFocused: () => Promise setWindowFocus: () => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 966fb0c0bf3a..e98d1cd0c229 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -303,9 +303,15 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { const image = await window.api.readClipboardImage().catch(() => null) if (!image) return null const blob = new Blob([image.buffer], { type: "image/png" }) - return new File([blob], `pasted-image-${Date.now()}.png`, { + const file = new File([blob], `pasted-image-${crypto.randomUUID()}.png`, { type: "image/png", }) + if (image.path) attachmentPaths.set(file, image.path) + return file + }, + + async persistTempImage(buffer: ArrayBuffer, ext: string) { + return window.api.persistTempImage(buffer, ext) }, } } diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 08f86f9f7a97..50fda178bfa4 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -1,5 +1,6 @@ import { execFile, spawn } from "node:child_process" -import { readFile, rm } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { access, readFile, rm } from "node:fs/promises" import { platform, release, tmpdir } from "node:os" import path from "node:path" import { promisify } from "node:util" @@ -26,9 +27,39 @@ function writeOsc52(text: string) { process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence) } +async function readClipboardFilePath() { + // «class furl» reads public.file-url (Finder Cmd+C) and alias records as a file reference. + // Plain text is coercible too, so verify the path exists before trusting it. + try { + const result = await exec("osascript", [ + "-e", + `try + set theFile to the clipboard as «class furl» + try + return POSIX path of theFile + on error + set fileList to theFile as list + return POSIX path of (item 1 of fileList) + end try +on error + return "" +end try`, + ]) + const filePath = result.stdout.toString().trim() + if (!filePath) return undefined + await access(filePath) + return filePath + } catch { + return undefined + } +} + export async function read() { if (platform() === "darwin") { - const file = path.join(tmpdir(), "opencode-clipboard.png") + const filePath = await readClipboardFilePath() + if (filePath) return { data: filePath, mime: "text/plain" } + + const file = path.join(tmpdir(), `opencode-clipboard-${randomUUID()}.png`) try { await exec("osascript", [ "-e", @@ -42,10 +73,8 @@ export async function read() { "-e", "close access fileRef", ]) - return { data: (await readFile(file)).toString("base64"), mime: "image/png" } + return { data: (await readFile(file)).toString("base64"), mime: "image/png", path: file } } catch { - // Fall through to text clipboard. - } finally { await rm(file, { force: true }).catch(() => {}) } } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index d4253b28ba62..b2c9909ccc1a 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -379,6 +379,7 @@ export function Prompt(props: PromptProps) { if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", + filepath: content.path, mime: content.mime, content: content.data, }) @@ -1194,10 +1195,26 @@ export function Prompt(props: PromptProps) { async function pasteInputText(text: string) { const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() - const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform) + let filepath = pastedFilepath(pastedContent, terminalEnvironment.platform) const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { - const attachment = await readLocalAttachment(filepath) + let attachment = await readLocalAttachment(filepath) + // Bracketed paste only delivers the clipboard's text flavor, which for a + // copied file is the bare filename. Resolve the full path via the platform + // clipboard (which can read the file URL) and retry. + if (!attachment && clipboard.read) { + const clip = await clipboard.read() + if (clip?.mime === "text/plain" && clip.data && clip.data !== pastedContent) { + const clipPath = pastedFilepath(clip.data, terminalEnvironment.platform) + if (clipPath !== filepath) { + const retry = await readLocalAttachment(clipPath) + if (retry) { + attachment = retry + filepath = clipPath + } + } + } + } const filename = path.basename(filepath) if (attachment?.type === "text") { pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`) @@ -1257,7 +1274,7 @@ export function Prompt(props: PromptProps) { const part: Omit = { type: "file" as const, mime: file.mime, - filename: file.filename, + filename: file.filepath ?? file.filename, url: `data:${file.mime};base64,${file.content}`, source: { type: "file", diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx index 6e0ac5370e1a..8ffac74bd72a 100644 --- a/packages/tui/src/context/clipboard.tsx +++ b/packages/tui/src/context/clipboard.tsx @@ -1,7 +1,7 @@ import { createContext, type JSX, useContext } from "solid-js" import { read, write } from "../clipboard" -export type ClipboardContent = Readonly<{ data: string; mime: string }> +export type ClipboardContent = Readonly<{ data: string; mime: string; path?: string }> export type ClipboardService = Readonly<{ read?(): Promise write?(text: string): Promise From 3b4b0dcc2826fbb63e5514f04199ebe8475badc2 Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Sun, 12 Jul 2026 21:56:56 +0530 Subject: [PATCH 2/2] fix(tui): stop leaking clipboard image temp files Addresses Opus review of PR #35: clipboard image reads returned a unique-named temp PNG whose cleanup only ran on the failure path, leaking one file per paste. Route them through a single per-process temp dir removed on process exit. --- packages/tui/src/clipboard.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 50fda178bfa4..af913aa87947 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -1,5 +1,6 @@ import { execFile, spawn } from "node:child_process" import { randomUUID } from "node:crypto" +import { mkdirSync, rmSync } from "node:fs" import { access, readFile, rm } from "node:fs/promises" import { platform, release, tmpdir } from "node:os" import path from "node:path" @@ -7,6 +8,19 @@ import { promisify } from "node:util" const exec = promisify(execFile) +// Clipboard image reads return a file path so path-based MCP tools can read it, +// so the temp file must outlive read(). To avoid leaking one PNG per paste, they +// go in a single per-process dir that is removed when the process exits. +let clipboardImageDir: string | undefined +function clipboardImagePath() { + if (!clipboardImageDir) { + clipboardImageDir = path.join(tmpdir(), `opencode-clipboard-${randomUUID()}`) + mkdirSync(clipboardImageDir, { recursive: true }) + process.once("exit", () => rmSync(clipboardImageDir!, { recursive: true, force: true })) + } + return path.join(clipboardImageDir, `${randomUUID()}.png`) +} + function command(command: string, args: string[] = [], input?: string) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] }) @@ -59,7 +73,7 @@ export async function read() { const filePath = await readClipboardFilePath() if (filePath) return { data: filePath, mime: "text/plain" } - const file = path.join(tmpdir(), `opencode-clipboard-${randomUUID()}.png`) + const file = clipboardImagePath() try { await exec("osascript", [ "-e",