Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ extension RemoteSessionCoordinator {
cancelReverseRelayRestartLocked()
cancelRemotePortScanCoalesceLocked()
let cleanupSucceeded = stopReverseRelayLocked(cleanupScope: cleanupScope)
if cleanupSucceeded {
cleanupRemotePasteDirectoryLocked()
}
remotePortScanGeneration &+= 1
remotePortScanBurstTask?.cancel()
remotePortScanBurstTask = nil
Expand Down
Original file line number Diff line number Diff line change
@@ -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-<uuid>` 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,
Expand Down Expand Up @@ -62,14 +58,16 @@ 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
guard normalizedLocalURL.isFileURL else {
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.
Expand Down Expand Up @@ -99,6 +97,7 @@ extension RemoteSessionCoordinator {
"scp exited \(scpResult.status)"
throw RemoteDropUploadError.uploadFailed(detail)
}
try finalizeRemotePasteFileLocked(remotePath)
}
return uploadedRemotePaths
} catch {
Expand All @@ -107,22 +106,57 @@ extension RemoteSessionCoordinator {
}
}

/// The `/tmp/cmux-drop-<uuid>[.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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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: ())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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.pngtouch")
#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)
}
}
2 changes: 1 addition & 1 deletion Sources/TerminalCustomUploadRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading