From b5d370797ab32de96e87b9099612c8ffa8a38266 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 15:13:15 -0700 Subject: [PATCH 1/2] test: cover private remote paste cleanup policy Co-Authored-By: Claude Opus 5.5 --- .../RemotePasteFileTransferPolicyTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift new file mode 100644 index 000000000000..2e588acc82b7 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import CmuxRemoteSession + +@Suite("Remote paste file transfer policy") +struct RemotePasteFileTransferPolicyTests { + @Test("remote paths use a private random directory and sanitized extension") + func remotePathUsesPrivateRandomName() { + let policy = RemotePasteFileTransferPolicy( + sessionID: UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")! + ) + let path = policy.remotePath( + for: URL(fileURLWithPath: "/tmp/clipboard image.PnG;touch") , + uuid: UUID(uuidString: "01234567-89AB-CDEF-0123-456789ABCDEF")! + ) + + #expect(path == "~/.cache/cmux/paste/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/cmux-paste-01234567-89ab-cdef-0123-456789abcdef.png") + #expect(!path.contains("/tmp")) + #expect(!path.contains(";")) + } + + @Test("maintenance removes old files and trims oldest files over the cap") + func maintenanceCleansByAgeAndSize() throws { + let policy = RemotePasteFileTransferPolicy( + sessionID: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, + maximumByteCount: 10 + ) + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-remote-paste-\(UUID().uuidString)", isDirectory: true) + let directory = home.appendingPathComponent( + ".cache/cmux/paste/11111111-2222-3333-4444-555555555555", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let stale = directory.appendingPathComponent("cmux-paste-stale.png") + let old = directory.appendingPathComponent("cmux-paste-old.png") + let newest = directory.appendingPathComponent("cmux-paste-new.png") + try Data(repeating: 0, count: 1).write(to: stale) + try Data(repeating: 1, count: 8).write(to: old) + try Data(repeating: 2, count: 8).write(to: newest) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -(policy.maximumAge + 60))], + ofItemAtPath: stale.path + ) + + try runShell(policy.maintenanceScript(), home: home) + + #expect(!FileManager.default.fileExists(atPath: stale.path)) + #expect(!FileManager.default.fileExists(atPath: old.path)) + #expect(FileManager.default.fileExists(atPath: newest.path)) + #expect(try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? NSNumber == 0o700) + } + + @Test("teardown cleanup removes only cmux paste files") + func teardownCleanupRemovesPasteFiles() throws { + let policy = RemotePasteFileTransferPolicy( + sessionID: UUID(uuidString: "66666666-7777-8888-9999-aaaaaaaaaaaa")! + ) + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-remote-paste-teardown-\(UUID().uuidString)", isDirectory: true) + let directory = home.appendingPathComponent( + ".cache/cmux/paste/66666666-7777-8888-9999-aaaaaaaaaaaa", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let pasteFile = directory.appendingPathComponent("cmux-paste-one.png") + let otherFile = directory.appendingPathComponent("keep.txt") + try Data("paste".utf8).write(to: pasteFile) + try Data("keep".utf8).write(to: otherFile) + + try runShell(policy.teardownCleanupScript(), home: home) + + #expect(!FileManager.default.fileExists(atPath: pasteFile.path)) + #expect(FileManager.default.fileExists(atPath: otherFile.path)) + } + + private func runShell(_ script: String, home: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", script] + process.environment = ["HOME": home.path] + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + } +} From 6df2cdfbd6609d047f00f75914ef2a860a494aa7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 15:24:28 -0700 Subject: [PATCH 2/2] fix: upload pasted images into private ssh directories Co-Authored-By: Claude Opus 5.5 --- .../RemoteSessionCoordinator+Lifecycle.swift | 3 + .../RemoteSessionCoordinator+Upload.swift | 70 ++++++--- .../Session/RemoteSessionCoordinator.swift | 7 +- .../RemotePasteFileTransferPolicy.swift | 133 ++++++++++++++++++ .../RemotePasteFileTransferPolicyTests.swift | 2 +- Sources/TerminalCustomUploadRunner.swift | 2 +- Sources/TerminalSSHSessionDetector.swift | 104 +++++++++++++- cmuxTests/TerminalAndGhosttyTests.swift | 23 +++ cmuxTests/TerminalUploadCommandTests.swift | 4 +- .../WorkspaceRemoteConnectionTests.swift | 6 +- 10 files changed, 327 insertions(+), 27 deletions(-) create mode 100644 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemotePasteFileTransferPolicy.swift diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift index 4b2613bc1d8c..844ffe4985e0 100644 --- a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift @@ -27,6 +27,9 @@ extension RemoteSessionCoordinator { cancelReverseRelayRestartLocked() cancelRemotePortScanCoalesceLocked() let cleanupSucceeded = stopReverseRelayLocked(cleanupScope: cleanupScope) + if cleanupSucceeded { + cleanupRemotePasteDirectoryLocked() + } remotePortScanGeneration &+= 1 remotePortScanBurstTask?.cancel() remotePortScanBurstTask = nil diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Upload.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Upload.swift index a0151aafab40..a1cb1b4c2998 100644 --- a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Upload.swift +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Upload.swift @@ -1,17 +1,13 @@ internal import CmuxCore public import Foundation -// Drag-and-drop file upload onto the remote host over scp, with rollback of -// already-uploaded files on failure or cancellation. Faithful lift: scp argv -// composition, the `/tmp/cmux-drop-` path shape, cleanup script text, -// and the completion/cancellation ordering (including the main-queue -// completion hop) are pinned legacy behavior. (The legacy no-operation -// convenience overload was dead code — the workspace model always passes an -// operation — and was dropped rather than re-created around an app type.) +// File upload onto the remote host over scp, with rollback of already-uploaded +// files on failure or cancellation. The transfer still uses the existing SCP +// path; the remote destination is now a private, per-session directory with +// bounded age and size cleanup. extension RemoteSessionCoordinator { - /// Uploads dropped local files to `/tmp/cmux-drop-*` paths on the remote - /// host, completing on the main queue with the remote paths (or rolling - /// back uploads and failing when cancelled). + /// Uploads local files to private per-session paths on the remote host, + /// completing on the main queue with the remote paths. public func uploadDroppedFiles( _ fileURLs: [URL], operation: any RemoteTransferCancelling, @@ -62,6 +58,8 @@ extension RemoteSessionCoordinator { let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) var uploadedRemotePaths: [String] = [] do { + try operation.throwIfCancelled() + try prepareRemotePasteDirectoryLocked() for localURL in fileURLs { try operation.throwIfCancelled() let normalizedLocalURL = localURL.standardizedFileURL @@ -69,7 +67,7 @@ extension RemoteSessionCoordinator { throw RemoteDropUploadError.invalidFileURL } - let remotePath = Self.remoteDropPath(for: normalizedLocalURL) + let remotePath = remotePastePolicy.remotePath(for: normalizedLocalURL) uploadedRemotePaths.append(remotePath) // SCP's stream is a batch protocol; a remote PTY would corrupt // its framing even when an interactive workspace requested one. @@ -99,6 +97,7 @@ extension RemoteSessionCoordinator { "scp exited \(scpResult.status)" throw RemoteDropUploadError.uploadFailed(detail) } + try finalizeRemotePasteFileLocked(remotePath) } return uploadedRemotePaths } catch { @@ -107,22 +106,57 @@ extension RemoteSessionCoordinator { } } - /// The `/tmp/cmux-drop-[.ext]` remote path a dropped local file - /// uploads to (lowercased extension). Static and pinned by tests; also - /// used by the foreground-SSH drop path. + /// The private remote path a dropped or pasted local file uploads to. + /// Kept as a compatibility entry point for callers that only need a + /// path-shaped fixture; production uploads use the coordinator's session + /// policy so teardown can remove only its own files. public static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { - let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" - return "/tmp/cmux-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" + RemotePasteFileTransferPolicy( + sessionID: UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + ).remotePath(for: fileURL, uuid: uuid) } func cleanupUploadedRemotePaths(_ remotePaths: [String]) { guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(\.shellSingleQuoted).joined(separator: " ") + let cleanupScript = remotePastePolicy.cleanupScript(for: remotePaths) let cleanupCommand = "sh -c \(cleanupScript.shellSingleQuoted)" _ = try? sshExec( arguments: sshCommonArguments(batchMode: true) + ["--", configuration.destination, cleanupCommand], timeout: 8 ) } + + private func prepareRemotePasteDirectoryLocked() throws { + let command = "sh -c \(remotePastePolicy.maintenanceScript().shellSingleQuoted)" + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + ["--", configuration.destination, command], + timeout: 12 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + throw RemoteDropUploadError.uploadFailed(detail) + } + } + + private func finalizeRemotePasteFileLocked(_ remotePath: String) throws { + let command = "sh -c \(remotePastePolicy.finalizeScript(for: remotePath).shellSingleQuoted)" + let result = try sshExec( + arguments: sshCommonArguments(batchMode: true) + ["--", configuration.destination, command], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + throw RemoteDropUploadError.uploadFailed(detail) + } + } + + func cleanupRemotePasteDirectoryLocked() { + let command = "sh -c \(remotePastePolicy.teardownCleanupScript().shellSingleQuoted)" + _ = try? sshExec( + arguments: sshCommonArguments(batchMode: true) + ["--", configuration.destination, command], + timeout: 8 + ) + } } diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift index ed0803c69a29..98f4e1e7f113 100644 --- a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift @@ -65,6 +65,8 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { let codexWrapperScript: String? let daemonStrings: RemoteDaemonStrings let strings: RemoteSessionStrings + /// Private per-session directory policy for files uploaded from the clipboard or Finder. + let remotePastePolicy: RemotePasteFileTransferPolicy /// Sleep seam for every legacy `asyncAfter` delay (reconnect backoff, /// relay restart, bootstrap-TTY retry, port-scan coalesce and burst). let clock: any RemoteProxyRetryClock @@ -174,6 +176,7 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { /// - buildInfo: App-build inputs (`Bundle.main` stays app-side). /// - daemonStrings: App-localized daemon error strings. /// - strings: App-localized connection-state strings. + /// - remotePastePolicy: Private directory and cleanup policy for uploaded files. /// - clock: Sleep seam driving every retry/backoff delay (production /// default: the continuous clock). public init( @@ -190,7 +193,8 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { codexWrapperScript: String? = nil, daemonStrings: RemoteDaemonStrings, strings: RemoteSessionStrings, - clock: any RemoteProxyRetryClock = SystemRemoteProxyRetryClock() + clock: any RemoteProxyRetryClock = SystemRemoteProxyRetryClock(), + remotePastePolicy: RemotePasteFileTransferPolicy = RemotePasteFileTransferPolicy() ) { self.host = host self.configuration = configuration @@ -205,6 +209,7 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { self.codexWrapperScript = codexWrapperScript self.daemonStrings = daemonStrings self.strings = strings + self.remotePastePolicy = remotePastePolicy self.clock = clock queue.setSpecific(key: queueKey, value: ()) } diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemotePasteFileTransferPolicy.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemotePasteFileTransferPolicy.swift new file mode 100644 index 000000000000..e7e3c58c9067 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Values/RemotePasteFileTransferPolicy.swift @@ -0,0 +1,133 @@ +import Foundation + +/// Owns the private remote directory and cleanup contract for pasted files. +public struct RemotePasteFileTransferPolicy: Equatable, Sendable { + /// The maximum number of bytes retained in one session's paste directory. + public let maximumByteCount: Int64 + + /// The age after which an uploaded paste file is eligible for cleanup. + public let maximumAge: TimeInterval + + /// The random directory identity for one remote session. + public let sessionID: UUID + + /// Creates a policy for one remote session. + public init( + sessionID: UUID = UUID(), + maximumByteCount: Int64 = 200 * 1024 * 1024, + maximumAge: TimeInterval = 24 * 60 * 60 + ) { + self.sessionID = sessionID + self.maximumByteCount = max(1, maximumByteCount) + self.maximumAge = max(60, maximumAge) + } + + /// Returns the shell path used by SCP for an uploaded file. + public func remotePath(for fileURL: URL, uuid: UUID = UUID()) -> String { + let suffix = sanitizedExtension(fileURL.pathExtension) + let extensionSuffix = suffix.isEmpty ? "" : "." + suffix + let fileName = "cmux-paste-" + uuid.uuidString.lowercased() + extensionSuffix + return "~/" + relativeDirectoryPath + "/" + fileName + } + + /// Returns a shell script that creates the private directory and removes stale or oversized files. + public func maintenanceScript() -> String { + let directory = shellDirectoryExpression + let ageMinutes = max(1, Int(maximumAge / 60)) + return [ + "set -eu", + "dir=" + directory, + "umask 077", + "mkdir -p \"$dir\"", + "chmod 700 \"$dir\"", + "find \"$dir\" -type f -name 'cmux-paste-*' -mmin +" + String(ageMinutes) + " -delete", + "total=0", + "for file in \"$dir\"/cmux-paste-*; do", + " [ -f \"$file\" ] || continue", + " bytes=$(wc -c < \"$file\" 2>/dev/null || printf '0')", + " total=$((total + bytes))", + "done", + "while [ \"$total\" -gt " + String(maximumByteCount) + " ]; do", + " oldest=''", + " oldest_mtime=9223372036854775807", + " for file in \"$dir\"/cmux-paste-*; do", + " [ -f \"$file\" ] || continue", + " mtime=$(stat -c %Y \"$file\" 2>/dev/null || stat -f %m \"$file\" 2>/dev/null || printf '0')", + " if [ \"$mtime\" -lt \"$oldest_mtime\" ]; then", + " oldest=\"$file\"", + " oldest_mtime=\"$mtime\"", + " fi", + " done", + " [ -n \"$oldest\" ] || break", + " bytes=$(wc -c < \"$oldest\" 2>/dev/null || printf '0')", + " rm -f -- \"$oldest\"", + " total=$((total - bytes))", + "done", + ].joined(separator: "\n") + } + + /// Returns a shell script that enforces mode `0600` after SCP creates a file. + public func finalizeScript(for remotePath: String) -> String { + let prefix = "~/" + relativeDirectoryPath + "/" + guard remotePath.hasPrefix(prefix), + let fileName = remotePath.split(separator: "/").last, + fileName.hasPrefix("cmux-paste-") else { + return "false" + } + let path = "\"$HOME/" + relativeDirectoryPath + "/" + String(fileName) + "\"" + return "chmod 600 -- \(path) && test -f \(path)" + } + + /// Returns a shell script that removes only files owned by this policy. + public func cleanupScript(for remotePaths: [String]) -> String { + let prefix = "~/" + relativeDirectoryPath + "/" + let fileNames = remotePaths.compactMap { remotePath -> String? in + guard remotePath.hasPrefix(prefix), + let fileName = remotePath.split(separator: "/").last, + fileName.hasPrefix("cmux-paste-") else { + return nil + } + return String(fileName) + } + guard fileNames.count == remotePaths.count, !fileNames.isEmpty else { + return "true" + } + let paths = fileNames.map { + "\"$HOME/" + relativeDirectoryPath + "/" + $0 + "\"" + }.joined(separator: " ") + return "rm -f -- " + paths + } + + /// Returns a shell script that removes this session's paste files after relay teardown. + public func teardownCleanupScript() -> String { + let directory = shellDirectoryExpression + return [ + "set -eu", + "dir=" + directory, + "if [ -d \"$dir\" ]; then", + " find \"$dir\" -type f -name 'cmux-paste-*' -delete", + " rmdir \"$dir\" 2>/dev/null || true", + "fi", + ].joined(separator: "\n") + } + + private var relativeDirectoryPath: String { + ".cache/cmux/paste/" + sessionID.uuidString.lowercased() + } + + private var shellDirectoryExpression: String { + "\"$HOME/" + relativeDirectoryPath + "\"" + } + + private func sanitizedExtension(_ value: String) -> String { + let lowered = value.lowercased() + let scalars = lowered.unicodeScalars.prefix(16) + var result = "" + for scalar in scalars where + (scalar.value >= 48 && scalar.value <= 57) || + (scalar.value >= 97 && scalar.value <= 122) { + result.unicodeScalars.append(scalar) + } + return result + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift index 2e588acc82b7..c30b801c3cef 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePasteFileTransferPolicyTests.swift @@ -14,7 +14,7 @@ struct RemotePasteFileTransferPolicyTests { uuid: UUID(uuidString: "01234567-89AB-CDEF-0123-456789ABCDEF")! ) - #expect(path == "~/.cache/cmux/paste/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/cmux-paste-01234567-89ab-cdef-0123-456789abcdef.png") + #expect(path == "~/.cache/cmux/paste/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/cmux-paste-01234567-89ab-cdef-0123-456789abcdef.pngtouch") #expect(!path.contains("/tmp")) #expect(!path.contains(";")) } diff --git a/Sources/TerminalCustomUploadRunner.swift b/Sources/TerminalCustomUploadRunner.swift index 23c74016df9f..32d272e1ea11 100644 --- a/Sources/TerminalCustomUploadRunner.swift +++ b/Sources/TerminalCustomUploadRunner.swift @@ -113,7 +113,7 @@ struct TerminalCustomUploadRunner { guard normalizedLocalURL.isFileURL else { throw Self.uploadError("Dropped item is not a local file.") } - let remotePath = RemoteSessionCoordinator.remoteDropPath(for: normalizedLocalURL) + let remotePath = session.remotePastePolicy.remotePath(for: normalizedLocalURL) let env = TerminalUploadCommand.environment( localPath: normalizedLocalURL.path, remotePath: remotePath, diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift index 196868caf255..5c4cc39c0d06 100644 --- a/Sources/TerminalSSHSessionDetector.swift +++ b/Sources/TerminalSSHSessionDetector.swift @@ -17,6 +17,49 @@ struct DetectedSSHSession: Equatable, Sendable { let forwardAgent: Bool let compressionEnabled: Bool let sshOptions: [String] + let remotePastePolicy: RemotePasteFileTransferPolicy + + static func == (lhs: DetectedSSHSession, rhs: DetectedSSHSession) -> Bool { + lhs.destination == rhs.destination && + lhs.port == rhs.port && + lhs.identityFile == rhs.identityFile && + lhs.configFile == rhs.configFile && + lhs.jumpHost == rhs.jumpHost && + lhs.controlPath == rhs.controlPath && + lhs.useIPv4 == rhs.useIPv4 && + lhs.useIPv6 == rhs.useIPv6 && + lhs.forwardAgent == rhs.forwardAgent && + lhs.compressionEnabled == rhs.compressionEnabled && + lhs.sshOptions == rhs.sshOptions + } + + init( + destination: String, + port: Int?, + identityFile: String?, + configFile: String?, + jumpHost: String?, + controlPath: String?, + useIPv4: Bool, + useIPv6: Bool, + forwardAgent: Bool, + compressionEnabled: Bool, + sshOptions: [String], + remotePastePolicy: RemotePasteFileTransferPolicy = RemotePasteFileTransferPolicy() + ) { + self.destination = destination + self.port = port + self.identityFile = identityFile + self.configFile = configFile + self.jumpHost = jumpHost + self.controlPath = controlPath + self.useIPv4 = useIPv4 + self.useIPv6 = useIPv6 + self.forwardAgent = forwardAgent + self.compressionEnabled = compressionEnabled + self.sshOptions = sshOptions + self.remotePastePolicy = remotePastePolicy + } func uploadDroppedFiles( _ fileURLs: [URL], @@ -99,6 +142,8 @@ struct DetectedSSHSession: Equatable, Sendable { var uploadedRemotePaths: [String] = [] do { + try operation.throwIfCancelled() + try prepareRemotePasteDirectory() for localURL in fileURLs { try operation.throwIfCancelled() let normalizedLocalURL = localURL.standardizedFileURL @@ -111,7 +156,8 @@ struct DetectedSSHSession: Equatable, Sendable { ]) } - let remotePath = RemoteSessionCoordinator.remoteDropPath(for: normalizedLocalURL) + let remotePath = remotePastePolicy.remotePath(for: normalizedLocalURL) + uploadedRemotePaths.append(remotePath) let result = try Self.runProcess( executable: "/usr/bin/scp", arguments: scpArguments(localPath: normalizedLocalURL.path, remotePath: remotePath), @@ -146,7 +192,7 @@ struct DetectedSSHSession: Equatable, Sendable { ]) } - uploadedRemotePaths.append(remotePath) + try finalizeRemotePasteFile(remotePath) } return uploadedRemotePaths @@ -156,6 +202,58 @@ struct DetectedSSHSession: Equatable, Sendable { } } + private func prepareRemotePasteDirectory() throws { + let result = try Self.runProcess( + executable: "/usr/bin/ssh", + arguments: sshArguments(command: "sh -c \(remotePastePolicy.maintenanceScript().shellSingleQuoted)"), + timeout: 12 + ) + guard result.status == 0 else { + let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + let message = detail.isEmpty + ? String( + localized: "detectedSSH.fileDrop.error.uploadFailed", + defaultValue: "Couldn't upload the file to the remote session. Check that the remote host is reachable, then try again." + ) + : String.localizedStringWithFormat( + String( + localized: "detectedSSH.fileDrop.error.uploadFailedWithDetail", + defaultValue: "Couldn't upload the file to the remote session: %@" + ), + detail + ) + throw NSError(domain: "cmux.detected-ssh.drop", code: 3, userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + } + + private func finalizeRemotePasteFile(_ remotePath: String) throws { + let result = try Self.runProcess( + executable: "/usr/bin/ssh", + arguments: sshArguments(command: "sh -c \(remotePastePolicy.finalizeScript(for: remotePath).shellSingleQuoted)"), + timeout: 8 + ) + guard result.status == 0 else { + let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + let message = detail.isEmpty + ? String( + localized: "detectedSSH.fileDrop.error.uploadFailed", + defaultValue: "Couldn't upload the file to the remote session. Check that the remote host is reachable, then try again." + ) + : String.localizedStringWithFormat( + String( + localized: "detectedSSH.fileDrop.error.uploadFailedWithDetail", + defaultValue: "Couldn't upload the file to the remote session: %@" + ), + detail + ) + throw NSError(domain: "cmux.detected-ssh.drop", code: 4, userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + } + private func scpArguments(localPath: String, remotePath: String) -> [String] { var args: [String] = [ "-q", @@ -261,7 +359,7 @@ struct DetectedSSHSession: Equatable, Sendable { private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") + let cleanupScript = remotePastePolicy.cleanupScript(for: remotePaths) let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" _ = try? Self.runProcess( executable: "/usr/bin/ssh", diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 8eca6a7f7f1d..42bcf898928e 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -798,6 +798,29 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertEqual(completedText, "/tmp/cmux-drop-123.png") } + func testRemoteImagePasteFailureDoesNotInsertTheLocalClipboardPath() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-failure.png") + try make1x1PNG(color: .orange).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + var insertedText: String? + var failure: Error? + TerminalImageTransferPlanner.executeForTesting( + plan: .uploadFiles([url], .workspaceRemote), + uploadWorkspaceRemote: { _, _, finish in + finish(.failure(NSError(domain: "cmux.remote.paste", code: 1))) + }, + uploadDetectedSSH: { _, _, _, finish in + finish(.failure(NSError(domain: "unused", code: 0))) + }, + insertText: { insertedText = $0 }, + onFailure: { failure = $0 } + ) + + XCTAssertNil(insertedText) + XCTAssertNotNil(failure) + } + func testCancelledRemoteImagePasteExecutionSuppressesCompletionHandlers() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-cancel-test.png") try make1x1PNG(color: .brown).write(to: url) diff --git a/cmuxTests/TerminalUploadCommandTests.swift b/cmuxTests/TerminalUploadCommandTests.swift index 1709e353d2c1..261ad49d0137 100644 --- a/cmuxTests/TerminalUploadCommandTests.swift +++ b/cmuxTests/TerminalUploadCommandTests.swift @@ -254,7 +254,7 @@ import Testing commandStdout: "\u{1b}\u{01}\u{02}", remotePath: "/tmp/cmux-drop-x.png" ) - #expect(emitted.contains("cmux-drop")) + #expect(emitted.contains("cmux-paste")) } // MARK: - Environment @@ -389,7 +389,7 @@ import Testing return } // Falls back to the cmux-chosen remote path (escaped). - #expect(text.contains("cmux-drop")) + #expect(text.contains("cmux-paste")) } @Test func cancelledOperationFailsClosed() { diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index ce8ea9392198..4c9cb0c7323c 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -2122,7 +2122,11 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let remotePath = RemoteSessionCoordinator.remoteDropPath(for: fileURL, uuid: uuid) - XCTAssertEqual(remotePath, "/tmp/cmux-drop-12345678-1234-1234-1234-1234567890ab.png") + XCTAssertEqual( + remotePath, + "~/.cache/cmux/paste/00000000-0000-0000-0000-000000000000/" + + "cmux-paste-12345678-1234-1234-1234-1234567890ab.png" + ) } @MainActor