Support SSH file copy to OS clipboard (#6110)

* Support SSH file copy to OS clipboard

* Address SSH clipboard staging feedback
This commit is contained in:
Ricardo Sawir 2026-06-23 08:36:37 +07:00 committed by GitHub
parent d992062cf9
commit 4df2b800b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 427 additions and 44 deletions

View File

@ -12,6 +12,9 @@ const {
spawnMock,
childStdinEndMock,
resolveAuthorizedPathMock,
fsMkdirMock,
fsReaddirMock,
fsRmMock,
fsWriteFileMock,
fsStatMock,
clipboardReadTextMock,
@ -39,6 +42,9 @@ const {
return child
}),
resolveAuthorizedPathMock: vi.fn(),
fsMkdirMock: vi.fn(),
fsReaddirMock: vi.fn(),
fsRmMock: vi.fn(),
fsWriteFileMock: vi.fn(),
fsStatMock: vi.fn(),
clipboardReadTextMock: vi.fn(),
@ -56,6 +62,9 @@ vi.mock('node:child_process', () => ({
}))
vi.mock('node:fs/promises', () => ({
mkdir: fsMkdirMock,
readdir: fsReaddirMock,
rm: fsRmMock,
stat: fsStatMock,
default: {
writeFile: fsWriteFileMock
@ -111,6 +120,7 @@ import {
registerClipboardHandlers,
setTrustedClipboardRendererWebContentsId
} from './clipboard-ipc-handlers'
import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy'
function getRegisteredHandlers(): Map<string, (...args: unknown[]) => unknown> {
const handlers = new Map<string, (...args: unknown[]) => unknown>()
@ -150,6 +160,10 @@ function trackPromiseSettled(promise: Promise<unknown>): () => boolean {
return () => settled
}
function dirent(name: string, directory = true): { name: string; isDirectory: () => boolean } {
return { name, isDirectory: () => directory }
}
describe('registerClipboardHandlers', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(1760000000000)
@ -159,6 +173,12 @@ describe('registerClipboardHandlers', () => {
childStdinEndMock.mockClear()
resolveAuthorizedPathMock.mockReset()
resolveAuthorizedPathMock.mockImplementation(async (path: string) => path)
fsMkdirMock.mockReset()
fsMkdirMock.mockResolvedValue(undefined)
fsReaddirMock.mockReset()
fsReaddirMock.mockResolvedValue([])
fsRmMock.mockReset()
fsRmMock.mockResolvedValue(undefined)
fsWriteFileMock.mockReset()
fsStatMock.mockReset()
fsStatMock.mockResolvedValue({})
@ -256,6 +276,111 @@ describe('registerClipboardHandlers', () => {
}
})
it('sweeps expired remote clipboard staging directories', async () => {
const nowMs = 1760000000000
fsReaddirMock.mockResolvedValue([
dirent('orca-clipboard-file-expired'),
dirent('orca-clipboard-file-fresh'),
dirent('orca-clipboard-file-plain-file', false),
dirent('unrelated-temp')
])
fsStatMock.mockImplementation(async (targetPath: string) => {
if (targetPath.endsWith('expired')) {
return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 }
}
if (targetPath.endsWith('fresh')) {
return { mtimeMs: nowMs - 1000 }
}
throw new Error(`unexpected stat: ${targetPath}`)
})
await cleanupExpiredRemoteClipboardFiles(nowMs)
expect(fsRmMock).toHaveBeenCalledTimes(1)
expect(fsRmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), {
recursive: true,
force: true
})
})
it('materializes remote files before writing them to the OS clipboard', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockResolvedValue(undefined)
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
const tempDir = join(
'/tmp',
'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000'
)
const tempPath = join(tempDir, 'report.pdf')
await expect(
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), {
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
})
).resolves.toEqual({ ok: true })
expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf')
expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 })
expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath)
expect(fsStatMock).toHaveBeenCalledWith(tempPath)
expect(resolveAuthorizedPathMock).not.toHaveBeenCalled()
expect(fsRmMock).not.toHaveBeenCalled()
})
it('does not materialize remote directories for OS clipboard copy', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
await expect(
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), {
filePath: '/remote/src',
connectionId: 'ssh-1'
})
).resolves.toEqual({ ok: false, reason: 'is-directory' })
expect(provider.downloadFile).not.toHaveBeenCalled()
expect(fsMkdirMock).not.toHaveBeenCalled()
expect(clipboardWriteBufferMock).not.toHaveBeenCalled()
})
it('cleans up remote clipboard temp files when transfer fails', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed'))
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerClipboardHandlers({} as never)
const handlers = getRegisteredHandlers()
const tempDir = join(
'/tmp',
'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000'
)
const tempPath = join(tempDir, 'report.pdf')
await expect(
handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), {
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
})
).rejects.toThrow('transfer failed')
expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath)
expect(fsRmMock).toHaveBeenCalledWith(tempDir, { recursive: true, force: true })
expect(clipboardWriteBufferMock).not.toHaveBeenCalled()
})
it('rejects unauthorized local files before touching the OS clipboard', async () => {
resolveAuthorizedPathMock.mockRejectedValue(
new Error(

View File

@ -23,10 +23,23 @@ import {
assertClipboardImageByteLengthWithinLimit,
assertClipboardImageDimensionsWithinLimit
} from '../../shared/clipboard-image'
import { writeFileToClipboard } from './clipboard-file-copy'
import {
writeFileToClipboard,
type ClipboardFileDeps,
type ClipboardFileResult
} from './clipboard-file-copy'
import {
cleanupExpiredRemoteClipboardFiles,
writeRemoteFileToClipboard
} from './clipboard-remote-file-copy'
let trustedClipboardRendererWebContentsId: number | null = null
type ClipboardWriteFileRequest = {
filePath: string
connectionId?: string
}
export function setTrustedClipboardRendererWebContentsId(webContentsId: number | null): void {
trustedClipboardRendererWebContentsId = webContentsId
}
@ -53,6 +66,8 @@ export function registerClipboardHandlers(store: Store): void {
ipcMain.removeHandler('clipboard:writeFile')
ipcMain.removeHandler('clipboard:saveImageAsTempFile')
void cleanupExpiredRemoteClipboardFiles()
ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => {
assertTrustedClipboardSender(event)
return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options)
@ -80,13 +95,16 @@ export function registerClipboardHandlers(store: Store): void {
}
)
// Why: copy the actual file to the OS clipboard so pasting in Finder/Explorer
// drops the file itself, not its path as text. Local files only.
ipcMain.handle('clipboard:writeFile', (event, filePath: string) => {
assertTrustedClipboardSender(event)
return writeFileToClipboard(filePath, {
platform: process.platform,
desktop: process.env.XDG_CURRENT_DESKTOP,
resolveFilePath: async (path) => {
// drops the file itself, not its path as text.
ipcMain.handle(
'clipboard:writeFile',
(event, args: unknown): ClipboardFileResult | Promise<ClipboardFileResult> => {
assertTrustedClipboardSender(event)
const request = normalizeClipboardWriteFileRequest(args)
if (!request) {
return { ok: false, reason: 'invalid-path' }
}
const deps = makeClipboardFileDeps(async (path) => {
try {
const authorizedPath = await resolveAuthorizedPath(path, store)
await stat(authorizedPath)
@ -97,11 +115,17 @@ export function registerClipboardHandlers(store: Store): void {
}
return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' }
}
},
writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer),
runCommand
})
})
})
if (request.connectionId) {
return writeRemoteFileToClipboard({
remotePath: request.filePath,
connectionId: request.connectionId,
deps
})
}
return writeFileToClipboard(request.filePath, deps)
}
)
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
assertTrustedClipboardSender(event)
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
@ -151,6 +175,36 @@ export function registerClipboardHandlers(store: Store): void {
})
}
function normalizeClipboardWriteFileRequest(args: unknown): ClipboardWriteFileRequest | null {
if (typeof args === 'string') {
return { filePath: args }
}
if (!args || typeof args !== 'object' || Array.isArray(args)) {
return null
}
const filePath = (args as { filePath?: unknown }).filePath
if (typeof filePath !== 'string') {
return null
}
const connectionId = (args as { connectionId?: unknown }).connectionId
if (typeof connectionId === 'string' && connectionId.trim() !== '') {
return { filePath, connectionId }
}
return { filePath }
}
function makeClipboardFileDeps(
resolveFilePath: ClipboardFileDeps['resolveFilePath']
): ClipboardFileDeps {
return {
platform: process.platform,
desktop: process.env.XDG_CURRENT_DESKTOP,
resolveFilePath,
writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer),
runCommand
}
}
function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void {
if (!isTrustedClipboardRenderer(event.sender)) {
throw new Error('Unauthorized clipboard IPC sender')

View File

@ -0,0 +1,129 @@
import { randomUUID } from 'node:crypto'
import type { Dirent } from 'node:fs'
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { app } from 'electron'
import { getRuntimePathBasename } from '../../shared/cross-platform-path'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
writeFileToClipboard,
type ClipboardFileDeps,
type ClipboardFileResult
} from './clipboard-file-copy'
type RemoteClipboardFileDeps = Omit<ClipboardFileDeps, 'resolveFilePath'>
const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000
const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-'
const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i
const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*'])
export async function writeRemoteFileToClipboard({
remotePath,
connectionId,
deps
}: {
remotePath: string
connectionId: string
deps: RemoteClipboardFileDeps
}): Promise<ClipboardFileResult> {
const provider = requireSshFilesystemProvider(connectionId)
const remoteStat = await provider.stat(remotePath)
if (remoteStat.type === 'directory') {
return { ok: false, reason: 'is-directory' }
}
if (!provider.downloadFile) {
throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.')
}
const tempDir = join(
app.getPath('temp'),
`${REMOTE_CLIPBOARD_FILE_PREFIX}${Date.now()}-${randomUUID()}`
)
await mkdir(tempDir, { mode: 0o700 })
const localPath = join(
tempDir,
sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath))
)
let keepTempFile = false
try {
await provider.downloadFile(remotePath, localPath)
const result = await writeFileToClipboard(localPath, {
...deps,
resolveFilePath: async (path) => {
if (path !== localPath) {
return { ok: false, reason: 'invalid-path' }
}
try {
await stat(path)
return { ok: true, path }
} catch {
return { ok: false, reason: 'not-found' }
}
}
})
if (result.ok) {
// Why: OS file clipboards keep a path reference, so the staged copy must
// survive after this IPC call long enough for the user to paste it.
keepTempFile = true
scheduleRemoteClipboardFileCleanup(tempDir)
}
return result
} finally {
if (!keepTempFile) {
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
}
export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise<void> {
const tempRoot = app.getPath('temp')
let entries: Dirent[]
try {
entries = await readdir(tempRoot, { withFileTypes: true })
} catch {
return
}
await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) {
return
}
const tempDir = join(tempRoot, entry.name)
try {
const tempStats = await stat(tempDir)
if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) {
return
}
await rm(tempDir, { recursive: true, force: true })
} catch {
// Why: stale staged SSH files should not make startup cleanup noisy.
}
})
)
}
function sanitizeLocalClipboardFilename(remoteBasename: string): string {
const sanitized = Array.from(remoteBasename, (char) =>
char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char
)
.join('')
.replace(/[. ]+$/g, '')
if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) {
return 'download'
}
return sanitized
}
function scheduleRemoteClipboardFileCleanup(tempDir: string): void {
const timer = setTimeout(() => {
void rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}, REMOTE_CLIPBOARD_FILE_TTL_MS)
if (typeof timer === 'object' && 'unref' in timer) {
timer.unref()
}
}

View File

@ -2479,7 +2479,14 @@ export type PreloadApi = {
writeSelectionClipboardText: (text: string) => Promise<void>
writeClipboardImage: (dataUrl: string) => Promise<void>
performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void
writeClipboardFile: (filePath: string) => Promise<{ ok: boolean; reason?: string }>
writeClipboardFile: (
args:
| {
filePath: string
connectionId?: string | null
}
| string
) => Promise<{ ok: boolean; reason?: string }>
onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void

View File

@ -3270,8 +3270,14 @@ const api = {
mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste'
})
},
writeClipboardFile: (filePath: string): Promise<{ ok: boolean; reason?: string }> =>
ipcRenderer.invoke('clipboard:writeFile', filePath),
writeClipboardFile: (
args:
| {
filePath: string
connectionId?: string | null
}
| string
): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args),
onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) =>
subscribeNativeFileDrop(callback),
getZoomLevel: (): number => webFrame.getZoomLevel(),

View File

@ -12,6 +12,7 @@ import {
getNextNameFilterCollapsedPaths
} from './file-explorer-name-filter-projection'
import {
copyFileToOsClipboard,
downloadRemoteFile,
FileExplorerRow,
shouldShowCollapseFolderAction,
@ -575,14 +576,66 @@ describe('FileExplorerRow collapse folder action', () => {
expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false)
})
it('shows OS file copy only for single local desktop selections', () => {
expect(shouldShowCopyFileAction(null, 1)).toBe(true)
expect(shouldShowCopyFileAction(undefined, 2)).toBe(false)
expect(shouldShowCopyFileAction('ssh-1', 1)).toBe(false)
it('shows OS file copy for single local rows and SSH file rows on desktop', () => {
const previous = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
try {
expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(true)
expect(shouldShowCopyFileAction(directoryNode, null, 1)).toBe(true)
expect(shouldShowCopyFileAction(fileNode, undefined, 2)).toBe(false)
expect(shouldShowCopyFileAction(fileNode, 'ssh-1', 1)).toBe(true)
expect(shouldShowCopyFileAction(directoryNode, 'ssh-1', 1)).toBe(false)
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
expect(shouldShowCopyFileAction(null, 1)).toBe(false)
expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(false)
} finally {
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = previous
}
})
it('copies local and SSH file rows through the clipboard file API', async () => {
const writeClipboardFile = vi.fn().mockResolvedValue({ ok: true })
;(
globalThis as unknown as {
window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } }
}
).window = { api: { ui: { writeClipboardFile } } }
await copyFileToOsClipboard(fileNode)
await copyFileToOsClipboard(fileNode, 'ssh-1')
expect(writeClipboardFile).toHaveBeenNthCalledWith(1, '/repo/src/index.ts')
expect(writeClipboardFile).toHaveBeenNthCalledWith(2, {
filePath: '/repo/src/index.ts',
connectionId: 'ssh-1'
})
expect(toastErrorMock).not.toHaveBeenCalled()
})
it('shows a failure toast when OS file copy fails', async () => {
const writeClipboardFile = vi.fn().mockResolvedValue({ ok: false, reason: 'invalid-path' })
;(
globalThis as unknown as {
window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } }
}
).window = { api: { ui: { writeClipboardFile } } }
await copyFileToOsClipboard(fileNode)
expect(toastErrorMock).toHaveBeenCalledWith('Could not copy the file to the clipboard')
})
it('shows the remote copy rejection message when SSH materialization fails', async () => {
const writeClipboardFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped'))
;(
globalThis as unknown as {
window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } }
}
).window = { api: { ui: { writeClipboardFile } } }
await copyFileToOsClipboard(fileNode, 'ssh-1')
expect(toastErrorMock).toHaveBeenCalledWith('Remote connection dropped')
})
it('calls the preload download API and shows success only when not canceled', async () => {

View File

@ -314,11 +314,15 @@ export function shouldShowRemoteDownloadAction(
)
}
export function shouldShowCopyFileAction(connectionId?: string | null, selectionSize = 1): boolean {
// Why: the OS file clipboard only holds local files — remote (SSH) files
// don't exist on this machine, and the web client has no native clipboard.
export function shouldShowCopyFileAction(
node: TreeNode,
connectionId?: string | null,
selectionSize = 1
): boolean {
// Why: remote directories would require recursive materialization semantics;
// keep this to a single concrete file reference until multi-file copy exists.
return (
!connectionId &&
(!connectionId || !node.isDirectory) &&
selectionSize === 1 &&
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
)
@ -360,6 +364,26 @@ export async function downloadRemoteFile(node: TreeNode, connectionId: string):
}
}
export async function copyFileToOsClipboard(
node: TreeNode,
connectionId?: string | null
): Promise<void> {
const failureMessage = translate(
'auto.components.right.sidebar.FileExplorerRow.b234ab25b4',
'Could not copy the file to the clipboard'
)
try {
const result = await window.api.ui.writeClipboardFile(
connectionId ? { filePath: node.path, connectionId } : node.path
)
if (!result.ok) {
toast.error(failureMessage)
}
} catch (error) {
toast.error(extractIpcErrorMessage(error, failureMessage))
}
}
export function FileExplorerRow({
node,
isExpanded,
@ -403,7 +427,7 @@ export function FileExplorerRow({
const FileIcon = getFileTypeIcon(node.relativePath || node.name)
const rowDropDir = node.isDirectory ? node.path : targetDir
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId)
const showCopyFileAction = shouldShowCopyFileAction(connectionId, selectionSize)
const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize)
const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } =
useFileExplorerRowDrag({
rowDropDir,
@ -432,23 +456,8 @@ export function FileExplorerRow({
void downloadRemoteFile(node, connectionId)
}, [connectionId, node])
const handleCopyFile = useCallback(() => {
const failureMessage = translate(
'auto.components.right.sidebar.FileExplorerRow.b234ab25b4',
'Could not copy the file to the clipboard'
)
void window.api.ui
.writeClipboardFile(node.path)
.then((result) => {
if (!result.ok) {
toast.error(failureMessage)
}
})
// A failure in the main process rejects the IPC promise; surface the same
// toast instead of leaving an unhandled rejection with no feedback.
.catch(() => {
toast.error(failureMessage)
})
}, [node.path])
void copyFileToOsClipboard(node, connectionId)
}, [connectionId, node])
return (
<ContextMenu