From 34d4767cde57244f73db1b9e3e77f1e003f72150 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 2 Aug 2026 16:22:50 -0700 Subject: [PATCH] Stop a text clipboard from being mistaken for a copied file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste over the ssh clipboard bridge failed for any ordinary text. The pasteboard is read richest-type-first — image, then file, then text — and the file probe decided "this is a file" by asking AppleScript to coerce the clipboard with `as «class furl»`. That coercion does not fail on plain text: macOS reinterprets the text as an HFS filename at the startup disk root, so a clipboard holding `3k tokens` coerces to the path `/3k tokens`, exit status 0. The probe then stat'd that path, failed, and propagated the error, which aborted the whole paste before the text fallback could run. Every text paste through the bridge died as `stat copied file /`. Ask the pasteboard which flavors it actually holds instead of inferring the type from a successful conversion: `clipboard info` reports `«class furl», 24` for a real Finder copy and only text flavors for text. Also degrade a failed stat to "not a file" rather than propagating it, so a speculative probe can never deny the user a paste it was only guessing about. The agent that runs this code lives in `construct ssh` on the machine the user is sitting at, so the fix has to be deployed there, not on the remote host. --- crates/cli/src/clipboard_bridge.rs | 61 +++++++++++++++++++++++++++++- specs/0098-ssh-clipboard-bridge.md | 10 +++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/clipboard_bridge.rs b/crates/cli/src/clipboard_bridge.rs index 9c3c4694..7bb38783 100644 --- a/crates/cli/src/clipboard_bridge.rs +++ b/crates/cli/src/clipboard_bridge.rs @@ -516,10 +516,42 @@ fn macos_clipboard_png() -> Option> { parse_osascript_data(&String::from_utf8_lossy(&out.stdout)) } +/// True when the pasteboard actually carries a file-URL flavor. +/// +/// This gate is load-bearing, not an optimization. Coercing the pasteboard +/// with `as «class furl»` *succeeds on plain text*: AppleScript reinterprets +/// the text as an HFS filename at the startup disk root, so a clipboard +/// holding `3k tokens` coerces to the path `/3k tokens`. Probing by coercion +/// alone therefore claims every text paste as a (nonexistent) file. Ask what +/// flavors are on the pasteboard instead — `clipboard info` reports +/// `«class furl», 24` for a real Finder copy and only text flavors otherwise. +#[cfg(target_os = "macos")] +fn macos_clipboard_has_file_url() -> bool { + let out = Command::new("osascript") + .args(["-e", "clipboard info"]) + .stderr(Stdio::null()) + .output(); + match out { + Ok(out) if out.status.success() => { + clipboard_info_has_file_url(&String::from_utf8_lossy(&out.stdout)) + } + _ => false, + } +} + +/// Whether `clipboard info` output lists the file-URL flavor. +#[cfg(target_os = "macos")] +fn clipboard_info_has_file_url(info: &str) -> bool { + info.contains("\u{ab}class furl\u{bb}") +} + /// A file copied in Finder, read off disk. Falls back to None when the /// pasteboard holds no file URL. #[cfg(target_os = "macos")] fn macos_clipboard_file() -> Result> { + if !macos_clipboard_has_file_url() { + return Ok(None); + } let out = Command::new("osascript") .args([ "-e", @@ -536,8 +568,12 @@ fn macos_clipboard_file() -> Result> { return Ok(None); } let path = PathBuf::from(path); - let meta = - std::fs::metadata(&path).with_context(|| format!("stat copied file {}", path.display()))?; + // A probe that cannot stat its candidate means "the pasteboard is not a + // readable file", not "the paste failed" — fall through to the text path + // rather than propagating and denying the user any paste at all. + let Ok(meta) = std::fs::metadata(&path) else { + return Ok(None); + }; if !meta.is_file() { return Ok(None); } @@ -718,6 +754,27 @@ mod tests { use super::*; use std::sync::Mutex; + /// The pasteboard flavor gate is what keeps a plain-text clipboard from + /// being misread as a copied file: `as «class furl»` coerces *any* text + /// into a root-relative path, so only the flavor list can tell the two + /// apart. Both fixtures are verbatim `clipboard info` output from macOS. + #[cfg(target_os = "macos")] + #[test] + fn clipboard_info_distinguishes_copied_file_from_plain_text() { + let text = + "\u{ab}class utf8\u{bb}, 9, \u{ab}class ut16\u{bb}, 20, string, 9, Unicode text, 18"; + assert!( + !clipboard_info_has_file_url(text), + "plain text must not be claimed as a copied file" + ); + + let file = "\u{ab}class furl\u{bb}, 24"; + assert!( + clipboard_info_has_file_url(file), + "a Finder file copy must be detected" + ); + } + #[test] fn ssh_argv_wraps_user_args_and_appends_remote_cmd() { let argv = build_ssh_argv( diff --git a/specs/0098-ssh-clipboard-bridge.md b/specs/0098-ssh-clipboard-bridge.md index 9f2b10e5..79b608d3 100644 --- a/specs/0098-ssh-clipboard-bridge.md +++ b/specs/0098-ssh-clipboard-bridge.md @@ -78,6 +78,16 @@ keeps setup at zero: one command, both ends are the same installed binary. `construct` ignores the environment variable (OSC 52 fallback applies), and a dead or missing bridge must fall back to the pre-bridge behavior rather than fail the copy or paste. +- Paste reads the pasteboard richest-type-first (image, then file, then + text), and each richer probe is speculative: failing to interpret the + clipboard as that type means "not that type", never "the paste failed". + A probe must fall through to the next type rather than propagate an + error, or a failure to read one flavor denies the user any paste at all. + Type detection must ask the pasteboard which flavors it actually holds; + it must not infer the type from a successful conversion, because + platform clipboard APIs will happily coerce plain text into a + nonsensical value of the richer type (on macOS, converting text to a + file URL yields a path built from the text itself). - The wrapper appends its own remote command, so users must not pass one positionally; overriding what runs remotely is an explicit flag.