diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db17bd2..f43033fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Remote programs can copy images to the local clipboard over SSH.** A saved + host can opt into OSC 5522 clipboard writes under **Advanced → Security → + Remote clipboard images**. PNG, JPEG, GIF and WebP transfers are decoded out + of band, capped at 16 MiB, validated before they reach the system clipboard, + and never enter scrollback or replay after reconnecting. The permission is + off by default; clipboard reads and SVG writes remain unsupported. + - **Documents dock beside the terminal** (#625). Opening a file, toggling the code panel or opening a diff no longer covers the workspace: the document takes a column to the right of the terminal — half the space between the diff --git a/crates/tty7-core/src/client/pane.rs b/crates/tty7-core/src/client/pane.rs index 7ff64f8b..8939208c 100644 --- a/crates/tty7-core/src/client/pane.rs +++ b/crates/tty7-core/src/client/pane.rs @@ -192,6 +192,7 @@ impl PaneSession { // put a workspace back the way its user left it. Nothing that // scripts panes through this library has a screen to put back. restore: None, + allow_remote_clipboard_write: false, } .encode(&mut stream)?; let mut session = PaneSession::over(stream, 0)?; @@ -224,7 +225,12 @@ impl PaneSession { size: WinSize, reply_wait: Duration, ) -> io::Result { - ClientMsg::Attach { pane_id, size }.encode(&mut stream)?; + ClientMsg::Attach { + pane_id, + size, + allow_remote_clipboard_write: false, + } + .encode(&mut stream)?; PaneSession::checked(stream, "Attach", pane_id, reply_wait) } diff --git a/crates/tty7-core/src/core/clipboard.rs b/crates/tty7-core/src/core/clipboard.rs new file mode 100644 index 00000000..508882d6 --- /dev/null +++ b/crates/tty7-core/src/core/clipboard.rs @@ -0,0 +1,769 @@ +//! Streaming OSC 5522 clipboard-write support. +//! +//! Clipboard packets are intercepted in the daemon before they reach the replay +//! ring. Completed writes travel to the GUI as compact binary frames; only the +//! GUI is allowed to touch the system clipboard. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; + +pub const MAX_CLIPBOARD_BYTES: usize = 16 << 20; +const MAX_CHUNK_BYTES: usize = 4096; +const MAX_OSC_PAYLOAD: usize = 8 << 10; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardWrite { + pub mime: String, + pub data: Vec, + pub id: Option, +} + +impl ClipboardWrite { + pub fn encode_frame(self) -> Vec { + let mime = self.mime.as_bytes(); + let id = self.id.as_deref().unwrap_or_default().as_bytes(); + let mut out = Vec::with_capacity(4 + mime.len() + id.len() + self.data.len()); + out.extend_from_slice(&(mime.len() as u16).to_le_bytes()); + out.extend_from_slice(&(id.len() as u16).to_le_bytes()); + out.extend_from_slice(mime); + out.extend_from_slice(id); + out.extend_from_slice(&self.data); + out + } + + pub fn decode_frame(frame: Vec) -> Option { + let mime_len = usize::from(u16::from_le_bytes(frame.get(..2)?.try_into().ok()?)); + let id_len = usize::from(u16::from_le_bytes(frame.get(2..4)?.try_into().ok()?)); + let data_at = 4usize.checked_add(mime_len)?.checked_add(id_len)?; + if data_at > frame.len() || frame.len() - data_at > MAX_CLIPBOARD_BYTES { + return None; + } + let mime = std::str::from_utf8(frame.get(4..4 + mime_len)?) + .ok()? + .to_string(); + let id = match id_len { + 0 => None, + _ => Some( + std::str::from_utf8(frame.get(4 + mime_len..data_at)?) + .ok()? + .to_string(), + ), + }; + Some(Self { + mime, + data: frame[data_at..].to_vec(), + id, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Write(ClipboardWrite), + Reply(Vec), +} + +#[derive(Default)] +struct WriteState { + id: Option, + mime: Option, + data: Vec, + failed: bool, +} + +#[derive(Default)] +struct Parser { + write: Option, + allowed: bool, + available: bool, +} + +impl Parser { + fn feed(&mut self, payload: &[u8]) -> Option { + if payload == b"probe" { + return Some(Event::Reply(capability_reply(self.allowed))); + } + if payload == b"invalid" { + return self.fail("EINVAL"); + } + let (metadata, data) = match payload.iter().position(|&b| b == b';') { + Some(at) => (&payload[..at], &payload[at + 1..]), + None => (payload, &[][..]), + }; + let metadata = std::str::from_utf8(metadata).ok()?; + let fields = metadata + .split(':') + .filter_map(|field| field.split_once('=')) + .collect::>(); + let kind = fields.iter().find(|(k, _)| *k == "type")?.1; + let id = fields + .iter() + .find(|(k, _)| *k == "id") + .map(|(_, value)| sanitize_id(value)) + .filter(|value| !value.is_empty()); + + match kind { + "read" => Some(Event::Reply(response_for("read", id.as_deref(), "ENOSYS"))), + "write" => { + if !self.allowed { + self.write = Some(WriteState { + id: id.clone(), + failed: true, + ..WriteState::default() + }); + return Some(Event::Reply(response(id.as_deref(), "EPERM"))); + } + if !self.available { + self.write = Some(WriteState { + id: id.clone(), + failed: true, + ..WriteState::default() + }); + return Some(Event::Reply(response(id.as_deref(), "EBUSY"))); + } + if fields + .iter() + .any(|(k, value)| *k == "loc" && *value == "primary") + { + self.write = Some(WriteState { + id: id.clone(), + failed: true, + ..WriteState::default() + }); + return Some(Event::Reply(response(id.as_deref(), "ENOSYS"))); + } + self.write = Some(WriteState { + id, + ..WriteState::default() + }); + None + } + "wdata" => self.feed_data(fields, data), + _ => None, + } + } + + fn feed_data(&mut self, fields: Vec<(&str, &str)>, payload: &[u8]) -> Option { + if self.write.as_ref()?.failed { + return None; + } + if !self.allowed { + return self.fail("EPERM"); + } + let state = self.write.as_mut()?; + let mime = fields + .iter() + .find(|(k, _)| *k == "mime") + .map(|(_, value)| *value); + + if mime.is_none() && payload.is_empty() { + let state = self.write.take()?; + let Some(mime) = state.mime else { + return Some(Event::Reply(response(state.id.as_deref(), "EINVAL"))); + }; + if state.data.is_empty() { + return Some(Event::Reply(response(state.id.as_deref(), "EINVAL"))); + } + return Some(Event::Write(ClipboardWrite { + mime, + data: state.data, + id: state.id, + })); + } + + let Some(encoded_mime) = mime else { + return self.fail("EINVAL"); + }; + let Ok(mime_bytes) = BASE64.decode(encoded_mime) else { + return self.fail("EINVAL"); + }; + let Ok(mime) = std::str::from_utf8(&mime_bytes) else { + return self.fail("EINVAL"); + }; + if !supported_mime(mime) || state.mime.as_deref().is_some_and(|current| current != mime) { + return self.fail("EINVAL"); + } + let Ok(chunk) = BASE64.decode(payload) else { + return self.fail("EINVAL"); + }; + if chunk.is_empty() + || chunk.len() > MAX_CHUNK_BYTES + || state.data.len().saturating_add(chunk.len()) > MAX_CLIPBOARD_BYTES + { + return self.fail("EINVAL"); + } + state.mime.get_or_insert_with(|| mime.to_string()); + state.data.extend_from_slice(&chunk); + None + } + + fn fail(&mut self, status: &str) -> Option { + let state = self.write.as_mut()?; + state.failed = true; + // Nothing reads a failed transfer's bytes again, and the state itself + // lives on until the next `type=write` or a change of controller. A + // sender that fails its last chunk on purpose would otherwise leave + // `MAX_CLIPBOARD_BYTES` of its own image parked in every pane it can + // reach, for as long as it likes. + state.data = Vec::new(); + Some(Event::Reply(response(state.id.as_deref(), status))) + } +} + +/// The DECRPM answer to `CSI ? 5522 $ p`: 1 for a mode that is set, 2 for one +/// this terminal knows but has switched off. A terminal that never heard of +/// OSC 5522 answers 0, so a sender can still tell "not supported" from "not +/// permitted here" — which it cannot do if the answer never moves. +fn capability_reply(allowed: bool) -> Vec { + match allowed { + true => b"\x1b[?5522;1$y".to_vec(), + false => b"\x1b[?5522;2$y".to_vec(), + } +} + +fn supported_mime(mime: &str) -> bool { + matches!( + mime, + "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" + ) +} + +fn sanitize_id(id: &str) -> String { + id.chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '.')) + .collect() +} + +pub fn response(id: Option<&str>, status: &str) -> Vec { + response_for("write", id, status) +} + +fn response_for(kind: &str, id: Option<&str>, status: &str) -> Vec { + let id = id + .map(sanitize_id) + .filter(|value| !value.is_empty()) + .map(|value| format!(":id={value}")) + .unwrap_or_default(); + format!("\x1b]5522;type={kind}:status={status}{id}\x1b\\").into_bytes() +} + +#[derive(Default)] +pub struct ClipboardSniffer { + tokenizer: Tokenizer, + parser: Parser, + controller_epoch: Option, +} + +impl ClipboardSniffer { + pub fn set_controller(&mut self, epoch: Option, allowed: bool) { + if self.controller_epoch != epoch { + self.parser.write = None; + self.controller_epoch = epoch; + } + self.parser.allowed = allowed; + self.parser.available = epoch.is_some(); + } + + pub fn sniff<'a>(&mut self, bytes: &'a [u8]) -> Sniffed<'a> { + if self.tokenizer.ground() && !might_contain_protocol(bytes) { + return Sniffed::Plain(bytes); + } + let Self { + tokenizer, parser, .. + } = self; + let segments = std::cell::RefCell::new(Vec::new()); + tokenizer.feed( + bytes, + |run| push_output(&mut segments.borrow_mut(), run), + |payload| { + if let Some(event) = parser.feed(payload) { + segments.borrow_mut().push(Segment::Event(event)); + } + }, + ); + Sniffed::Segments(segments.into_inner()) + } +} + +fn might_contain_protocol(bytes: &[u8]) -> bool { + const PREFIXES: [&[u8]; 2] = [b"\x1b]5522;", b"\x1b[?5522$p"]; + PREFIXES.iter().any(|prefix| { + memchr::memmem::find(bytes, prefix).is_some() + || (1..prefix.len()) + .any(|len| bytes.len() >= len && bytes[bytes.len() - len..] == prefix[..len]) + }) +} + +fn push_output(segments: &mut Vec, run: &[u8]) { + if run.is_empty() { + return; + } + if let Some(Segment::Output(out)) = segments.last_mut() { + out.extend_from_slice(run); + } else { + segments.push(Segment::Output(run.to_vec())); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Output(Vec), + Event(Event), +} + +pub enum Sniffed<'a> { + Plain(&'a [u8]), + Segments(Vec), +} + +#[derive(Default)] +struct Tokenizer { + state: TokenState, + buf: Vec, +} + +#[derive(Default, Clone, Copy, PartialEq, Eq)] +enum TokenState { + #[default] + Ground, + Esc, + Csi, + Osc, + OscEsc, + PassOsc, + PassOscEsc, + Drop, + DropEsc, +} + +impl Tokenizer { + fn ground(&self) -> bool { + self.state == TokenState::Ground + } + + fn feed( + &mut self, + bytes: &[u8], + mut on_output: impl FnMut(&[u8]), + mut on_payload: impl FnMut(&[u8]), + ) { + let mut i = 0; + while i < bytes.len() { + match self.state { + TokenState::Ground => match memchr::memchr(0x1b, &bytes[i..]) { + Some(off) => { + on_output(&bytes[i..i + off]); + self.state = TokenState::Esc; + i += off + 1; + } + None => { + on_output(&bytes[i..]); + return; + } + }, + TokenState::Esc => { + if bytes[i] == b']' { + self.buf.clear(); + self.state = TokenState::Osc; + i += 1; + } else if bytes[i] == b'[' { + self.buf.clear(); + self.state = TokenState::Csi; + i += 1; + } else { + on_output(b"\x1b"); + self.state = TokenState::Ground; + } + } + TokenState::Csi => { + const QUERY: &[u8] = b"?5522$p"; + self.buf.push(bytes[i]); + i += 1; + if self.buf.as_slice() == QUERY { + on_payload(b"probe"); + self.buf.clear(); + self.state = TokenState::Ground; + } else if !QUERY.starts_with(&self.buf) { + on_output(b"\x1b["); + on_output(&self.buf); + self.buf.clear(); + self.state = TokenState::Ground; + } + } + TokenState::Osc => match bytes[i] { + 0x07 => { + self.finish(false, &mut on_output, &mut on_payload); + i += 1; + } + 0x1b => { + self.state = TokenState::OscEsc; + i += 1; + } + b => { + self.buf.push(b); + i += 1; + let target = b"5522"; + let wrong_id = match self.buf.iter().position(|&b| b == b';') { + Some(pos) => &self.buf[..pos] != target, + None => !target.starts_with(&self.buf), + }; + if wrong_id { + on_output(b"\x1b]"); + on_output(&self.buf); + self.buf.clear(); + self.state = TokenState::PassOsc; + } else if self.buf.len() > MAX_OSC_PAYLOAD { + self.buf.clear(); + self.state = TokenState::Drop; + } + } + }, + TokenState::OscEsc => { + if bytes[i] == b'\\' { + self.finish(true, &mut on_output, &mut on_payload); + i += 1; + } else if bytes[i] == b']' { + self.buf.clear(); + self.state = TokenState::Osc; + i += 1; + } else { + self.buf.clear(); + self.state = TokenState::Ground; + } + } + TokenState::PassOsc => match memchr::memchr2(0x07, 0x1b, &bytes[i..]) { + Some(off) => { + on_output(&bytes[i..i + off + 1]); + self.state = if bytes[i + off] == 0x07 { + TokenState::Ground + } else { + TokenState::PassOscEsc + }; + i += off + 1; + } + None => { + on_output(&bytes[i..]); + return; + } + }, + TokenState::PassOscEsc => { + on_output(&bytes[i..i + 1]); + self.state = if bytes[i] == b'\\' { + TokenState::Ground + } else { + TokenState::PassOsc + }; + i += 1; + } + TokenState::Drop => match memchr::memchr2(0x07, 0x1b, &bytes[i..]) { + Some(off) => { + if bytes[i + off] == 0x07 { + on_payload(b"invalid"); + self.state = TokenState::Ground; + } else { + self.state = TokenState::DropEsc; + } + i += off + 1; + } + None => return, + }, + TokenState::DropEsc => { + if bytes[i] == b'\\' { + on_payload(b"invalid"); + self.state = TokenState::Ground; + } else if bytes[i] == b']' { + self.buf.clear(); + self.state = TokenState::Osc; + } else { + self.state = TokenState::Drop; + } + i += 1; + } + } + } + } + + fn finish( + &mut self, + st: bool, + on_output: &mut impl FnMut(&[u8]), + on_payload: &mut impl FnMut(&[u8]), + ) { + if let Some(payload) = self.buf.strip_prefix(b"5522;") { + on_payload(payload); + } else { + on_output(b"\x1b]"); + on_output(&self.buf); + on_output(if st { b"\x1b\\" } else { b"\x07" }); + } + self.buf.clear(); + self.state = TokenState::Ground; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn osc(metadata: &str, payload: &str) -> Vec { + let separator = if payload.is_empty() { "" } else { ";" }; + format!("\x1b]5522;{metadata}{separator}{payload}\x1b\\").into_bytes() + } + + fn collect(chunks: &[&[u8]]) -> (Vec, Vec) { + let mut sniffer = ClipboardSniffer::default(); + sniffer.set_controller(Some(1), true); + let mut output = Vec::new(); + let mut events = Vec::new(); + for chunk in chunks { + match sniffer.sniff(chunk) { + Sniffed::Plain(bytes) => output.extend_from_slice(bytes), + Sniffed::Segments(parts) => { + for part in parts { + match part { + Segment::Output(bytes) => output.extend(bytes), + Segment::Event(event) => events.push(event), + } + } + } + } + } + (output, events) + } + + #[test] + fn image_write_is_reassembled_and_stripped() { + let mime = BASE64.encode("image/png"); + let mut stream = b"before".to_vec(); + stream.extend(osc("type=write:id=req.1", "")); + stream.extend(osc( + &format!("type=wdata:mime={mime}"), + &BASE64.encode(b"png"), + )); + stream.extend(osc( + &format!("type=wdata:mime={mime}"), + &BASE64.encode(b"-data"), + )); + stream.extend(osc("type=wdata", "")); + stream.extend_from_slice(b"after"); + + let split = stream.len() / 2; + let (output, events) = collect(&[&stream[..split], &stream[split..]]); + assert_eq!(output, b"beforeafter"); + assert_eq!( + events, + vec![Event::Write(ClipboardWrite { + mime: "image/png".into(), + data: b"png-data".to_vec(), + id: Some("req.1".into()), + })] + ); + } + + #[test] + fn non_clipboard_osc_passes_through() { + let input = b"a\x1b]0;title\x07b\x1b]133;A\x1b\\c"; + let (output, events) = collect(&[input]); + assert_eq!(output, input); + assert!(events.is_empty()); + } + + #[test] + fn invalid_or_unsupported_data_is_rejected_once() { + let mime = BASE64.encode("image/svg+xml"); + let (output, events) = collect(&[ + &osc("type=write:id=bad/one", ""), + &osc( + &format!("type=wdata:mime={mime}"), + &BASE64.encode(b""), + ), + &osc( + &format!("type=wdata:mime={mime}"), + &BASE64.encode(b"ignored"), + ), + &osc("type=wdata", ""), + ]); + assert!(output.is_empty()); + assert_eq!( + events, + vec![Event::Reply(response(Some("badone"), "EINVAL"))] + ); + } + + #[test] + fn chunks_and_total_size_are_bounded() { + let mime = BASE64.encode("image/png"); + let too_big = vec![0u8; MAX_CHUNK_BYTES + 1]; + let (_, events) = collect(&[ + &osc("type=write", ""), + &osc(&format!("type=wdata:mime={mime}"), &BASE64.encode(too_big)), + ]); + assert_eq!(events, vec![Event::Reply(response(None, "EINVAL"))]); + + let mut parser = Parser { + allowed: true, + write: Some(WriteState { + mime: Some("image/png".into()), + data: vec![0; MAX_CLIPBOARD_BYTES], + ..WriteState::default() + }), + ..Parser::default() + }; + assert_eq!( + parser.feed_data( + vec![("type", "wdata"), ("mime", mime.as_str())], + BASE64.encode(b"x").as_bytes() + ), + Some(Event::Reply(response(None, "EINVAL"))) + ); + } + + #[test] + fn primary_selection_is_not_supported() { + let (_, events) = collect(&[&osc("type=write:loc=primary:id=x", "")]); + assert_eq!(events, vec![Event::Reply(response(Some("x"), "ENOSYS"))]); + } + + #[test] + fn disabled_writes_fail_before_any_image_data_is_buffered() { + let mut sniffer = ClipboardSniffer::default(); + let start = osc("type=write:id=denied", ""); + let events = match sniffer.sniff(&start) { + Sniffed::Segments(parts) => parts, + Sniffed::Plain(_) => panic!("OSC 5522 must be intercepted"), + }; + assert_eq!( + events, + vec![Segment::Event(Event::Reply(response( + Some("denied"), + "EPERM" + )))] + ); + assert!(sniffer.parser.write.as_ref().unwrap().data.is_empty()); + } + + #[test] + fn allowed_write_without_a_controller_is_busy() { + let mut sniffer = ClipboardSniffer::default(); + sniffer.set_controller(None, true); + let events = match sniffer.sniff(&osc("type=write:id=early", "")) { + Sniffed::Segments(parts) => parts, + Sniffed::Plain(_) => panic!("OSC 5522 must be intercepted"), + }; + assert_eq!( + events, + vec![Segment::Event(Event::Reply(response( + Some("early"), + "EBUSY" + )))] + ); + } + + #[test] + fn byte_at_a_time_delivery_preserves_text_and_reassembles_the_write() { + let mime = BASE64.encode("image/png"); + let data = BASE64.encode(b"png"); + let stream = format!( + "a\x1b]5522;type=write\x1b\\\ + \x1b]5522;type=wdata:mime={mime};{data}\x1b\\\ + \x1b]5522;type=wdata\x1b\\b" + ); + let chunks: Vec<&[u8]> = stream.as_bytes().chunks(1).collect(); + let (output, events) = collect(&chunks); + assert_eq!(output, b"ab"); + assert!(matches!( + events.as_slice(), + [Event::Write(ClipboardWrite { mime, data, .. })] + if mime == "image/png" && data == b"png" + )); + } + + #[test] + fn capability_probe_is_stripped_and_answered_across_reads() { + let (output, events) = collect(&[b"a\x1b[?55", b"22$p", b"b"]); + assert_eq!(output, b"ab"); + assert_eq!(events, vec![Event::Reply(capability_reply(true))]); + } + + /// The probe has to answer the permission actually in force. A sender that + /// reads DECRPM sees 2 — "this terminal knows the mode and it is off" — for + /// a host the user never opted in, and stops there instead of pushing an + /// image nobody will take. + #[test] + fn the_probe_reports_a_host_that_is_not_permitted_as_off() { + let mut sniffer = ClipboardSniffer::default(); + sniffer.set_controller(Some(1), false); + let events = match sniffer.sniff(b"\x1b[?5522$p") { + Sniffed::Segments(parts) => parts, + Sniffed::Plain(_) => panic!("the probe must be intercepted"), + }; + assert_eq!( + events, + vec![Segment::Event(Event::Reply(capability_reply(false)))] + ); + assert_ne!(capability_reply(true), capability_reply(false)); + } + + /// A transfer that fails half way stops being somewhere to park an image: + /// the reply goes out and the bytes go with it. + #[test] + fn a_failed_transfer_releases_what_it_had_buffered() { + let png = BASE64.encode("image/png"); + let gif = BASE64.encode("image/gif"); + let mut sniffer = ClipboardSniffer::default(); + sniffer.set_controller(Some(1), true); + let _ = sniffer.sniff(&osc("type=write:id=half", "")); + let _ = sniffer.sniff(&osc( + &format!("type=wdata:mime={png}"), + &BASE64.encode(b"png-bytes"), + )); + assert!(!sniffer.parser.write.as_ref().unwrap().data.is_empty()); + + let events = match sniffer.sniff(&osc( + &format!("type=wdata:mime={gif}"), + &BASE64.encode(b"gif"), + )) { + Sniffed::Segments(parts) => parts, + Sniffed::Plain(_) => panic!("OSC 5522 must be intercepted"), + }; + assert_eq!( + events, + vec![Segment::Event(Event::Reply(response( + Some("half"), + "EINVAL" + )))] + ); + assert!(sniffer.parser.write.as_ref().unwrap().data.is_empty()); + } + + #[test] + fn clipboard_reads_are_explicitly_unsupported() { + let (_, events) = collect(&[&osc("type=read:id=read-1", &BASE64.encode("image/png"))]); + assert_eq!( + events, + vec![Event::Reply(response_for("read", Some("read-1"), "ENOSYS"))] + ); + } + + #[test] + fn changing_controller_discards_an_unfinished_write() { + let mime = BASE64.encode("image/png"); + let mut sniffer = ClipboardSniffer::default(); + sniffer.set_controller(Some(1), true); + let _ = sniffer.sniff(&osc("type=write:id=old", "")); + let _ = sniffer.sniff(&osc( + &format!("type=wdata:mime={mime}"), + &BASE64.encode(b"old"), + )); + + sniffer.set_controller(Some(2), true); + let events = match sniffer.sniff(&osc("type=wdata", "")) { + Sniffed::Segments(parts) => parts, + Sniffed::Plain(_) => panic!("OSC 5522 must be intercepted"), + }; + assert!( + events.is_empty(), + "a new controller must not receive the old controller's partial write" + ); + } +} diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 07a649d3..eb5ba282 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -1,5 +1,6 @@ pub mod agent_hooks; pub mod cli_agent; +pub mod clipboard; pub mod codename; pub mod config; pub mod crash; diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 8fcbfdad..97a43d5c 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -35,6 +35,10 @@ pub struct SshProfile { pub skip_banner: bool, #[serde(default = "default_true")] pub shell_integration: bool, + /// Permit programs on this host to write image data to the local system + /// clipboard with OSC 5522. Off by default because terminal output is + /// otherwise enough to replace data outside the terminal. + pub remote_clipboard_write: bool, pub login_scripts: Vec, pub x11: bool, @@ -66,6 +70,7 @@ impl Default for SshProfile { warn_on_close: None, skip_banner: false, shell_integration: true, + remote_clipboard_write: false, login_scripts: Vec::new(), x11: false, algorithms: Algorithms::default(), @@ -542,6 +547,7 @@ mod tests { assert_eq!(p.port, 22); assert_eq!(p.auth, AuthMode::Auto); assert!(p.credential_ref.is_none()); + assert!(!p.remote_clipboard_write); let p: SshProfile = serde_json::from_str(r#"{"name":"old","host":"h","use_system_ssh":true}"#).unwrap(); @@ -567,6 +573,7 @@ mod tests { original.socks_proxy = Some(HostPort::new("proxy", 1080)); original.algorithms.kex = vec!["curve25519-sha256".to_string()]; original.credential_ref = Some(CredentialRef::password("deploy", "10.0.0.9", 2222)); + original.remote_clipboard_write = true; let json = serde_json::to_string(&original).unwrap(); let back: SshProfile = serde_json::from_str(&json).unwrap(); diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs index 5da98cc0..f17e520d 100644 --- a/crates/tty7-core/src/daemon/install/mod.rs +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -1092,7 +1092,7 @@ impl<'a> Installer<'a> { /// server started some other way would be invisible to it. It is still the /// better of the two answers available there. Linux's `comm` is not an answer /// at all — the name truncated to 15 characters, one short of -/// `tty7-server-c7p5` — which is why the fallback stays a fallback and `/proc` +/// `tty7-server-c7p6` — which is why the fallback stays a fallback and `/proc` /// keeps first refusal. /// /// Neither arm reaches past the connecting user: `readlink` on another user's diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index d2d9c9d7..832a8f37 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -9,6 +9,10 @@ use std::time::Duration; use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use crate::core::clipboard::{ + ClipboardSniffer, Event as ClipboardEvent, Segment as ClipboardSegment, + Sniffed as ClipboardSniffed, +}; use crate::core::kitty_graphics::{GraphicsSniffer, Segment, Sniffed}; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ @@ -673,6 +677,15 @@ struct PaneState { ring: ReplayRing, subscriber: Option>, subscriber_epoch: u64, + allow_remote_clipboard_write: bool, + /// A native ssh pane's answer belongs to the profile that dialled the + /// host, and the client attaching to it never sees that spec: a window + /// reopening onto a pane it outlived attaches by id and sends `false` + /// because it has nothing better to send. Pinning the spec's answer keeps + /// the permission in one place. `None` means "no spec of our own", which + /// is every pane on a remote `tty7-server` — there the controlling client + /// is the only one holding the profile's answer, so its word is taken. + clipboard_write_from_spec: Option, observers: Vec, observer_seq: u64, cwd: Option, @@ -788,6 +801,17 @@ fn fan_out_output(st: &mut PaneState, bytes: &[u8], frames: Vec, } // Ungated, and `notify` already holds observers to their budget. GraphicsFrame::Delete(sel) => notify(st, DaemonMsg::DeleteImage(sel)), + // Clipboard writes are side effects for the controlling GUI only. + // Observers must never overwrite their own machine's clipboard just + // because they are watching this pane. + GraphicsFrame::ClipboardWrite(frame) => { + let len = frame.len(); + if let Some(sub) = &st.subscriber + && sub.send(DaemonMsg::ClipboardWrite(frame)).is_ok() + { + gate.add(len); + } + } } } } @@ -974,6 +998,7 @@ enum GraphicsFrame { Output(Vec), Image(Vec), Delete(Vec), + ClipboardWrite(Vec), } /// Queue an encoded image frame for the subscriber, dropping any frame larger @@ -990,6 +1015,50 @@ fn push_image_frame(frames: &mut Vec, frame: Vec) { } } +fn process_graphics_output( + sniffed: Sniffed<'_>, + ordered: bool, + pass: &mut Vec, + frames: &mut Vec, + writer: &Arc>>, +) { + match sniffed { + Sniffed::Plain(bytes) => { + pass.extend_from_slice(bytes); + if ordered && !bytes.is_empty() { + frames.push(GraphicsFrame::Output(bytes.to_vec())); + } + } + Sniffed::Segments(segments) => { + for segment in segments { + match segment { + Segment::Output(bytes) => { + pass.extend_from_slice(&bytes); + frames.push(GraphicsFrame::Output(bytes)); + } + Segment::Query(reply) => { + if let Ok(mut writer) = writer.lock() { + let _ = writer.write_all(&reply); + let _ = writer.flush(); + } + } + Segment::Image(image) => { + push_image_frame(frames, image.encode_frame()); + } + Segment::ImageFromMedium(transfer) => { + if let Some(image) = transfer.resolve() { + push_image_frame(frames, image.encode_frame()); + } + } + Segment::Delete(delete) => { + frames.push(GraphicsFrame::Delete(delete.encode())); + } + } + } + } + } +} + /// A live pane, reduced to what survives an `exec` plus what has to be written /// down because it does not. /// @@ -1335,6 +1404,7 @@ impl DaemonPane { owner: Option, workspace: Option, restore: Option, + allow_remote_clipboard_write: bool, on_dead: impl FnOnce() + Send + 'static, ) -> anyhow::Result> { let pty_size = pty_size(size); @@ -1384,6 +1454,8 @@ impl DaemonPane { ring, subscriber: None, subscriber_epoch: 0, + allow_remote_clipboard_write, + clipboard_write_from_spec: None, observers: Vec::new(), observer_seq: 0, cwd: spawn.initial_cwd, @@ -1598,6 +1670,8 @@ impl DaemonPane { ring, subscriber: None, subscriber_epoch: 0, + allow_remote_clipboard_write: false, + clipboard_write_from_spec: None, observers: Vec::new(), observer_seq: 0, cwd: carried.cwd, @@ -1627,6 +1701,7 @@ impl DaemonPane { spec: Box, on_dead: impl FnOnce() + Send + 'static, ) -> anyhow::Result> { + let allow_remote_clipboard_write = spec.remote_clipboard_write; let bridge = crate::daemon::ssh::session::make_bridge(); let reader_handle: Box = Box::new(bridge.reader); let writer: Arc>> = @@ -1650,6 +1725,8 @@ impl DaemonPane { ring: ReplayRing::new(size), subscriber: None, subscriber_epoch: 0, + allow_remote_clipboard_write, + clipboard_write_from_spec: Some(allow_remote_clipboard_write), observers: Vec::new(), observer_seq: 0, // A native ssh pane is not running a shell of this machine's; what @@ -1761,6 +1838,7 @@ impl DaemonPane { .spawn(move || { crate::core::threads::promote_to_user_interactive(); let mut sniffer = OscSniffer::new(); + let mut clipboard = ClipboardSniffer::default(); // Kitty graphics interception (issue #213): lifts image // sequences out of the stream *before* the ring/subscriber see // them, so the base64 pixels never enter replay and the client's @@ -1816,13 +1894,21 @@ impl DaemonPane { tr_bytes += n as u64; } let raw = &buf[..n]; - // Kitty graphics: strip any image sequences out of the - // stream before anything else sees them. On the common - // no-graphics chunk this borrows `raw` unchanged; only a - // chunk that actually carries `\x1b_G…` allocates. Query - // replies are written straight back to the PTY here, and - // any images/deletes are forwarded to the subscriber - // out-of-band below — none of it enters the replay ring. + let (controller, allowed) = state + .lock() + .map(|st| { + ( + st.subscriber.as_ref().map(|_| st.subscriber_epoch), + st.allow_remote_clipboard_write, + ) + }) + .unwrap_or((None, false)); + clipboard.set_controller(controller, allowed); + // Strip OSC 5522 clipboard writes and Kitty graphics + // before anything else sees them. On the common path the + // original slice is borrowed unchanged; protocol payloads + // are decoded into out-of-band frames and never enter the + // replay ring. // // `frames` is the ordered list of out-of-band frames to // forward *in stream position*: a kitty image anchors to @@ -1833,45 +1919,59 @@ impl DaemonPane { // whole chunk is sent as one `Output`; only a chunk with // graphics splits into interleaved frames. let mut frames: Vec = Vec::new(); - let passthrough: std::borrow::Cow<[u8]> = match graphics.sniff(raw) { - Sniffed::Plain(b) => std::borrow::Cow::Borrowed(b), - Sniffed::Segments(segs) => { + let passthrough: std::borrow::Cow<[u8]> = match clipboard.sniff(raw) { + ClipboardSniffed::Plain(b) => match graphics.sniff(b) { + Sniffed::Plain(b) => std::borrow::Cow::Borrowed(b), + sniffed @ Sniffed::Segments(_) => { + let mut pass = Vec::new(); + process_graphics_output( + sniffed, + false, + &mut pass, + &mut frames, + &writer, + ); + std::borrow::Cow::Owned(pass) + } + }, + ClipboardSniffed::Segments(segs) => { let mut pass = Vec::new(); for seg in segs { match seg { - Segment::Output(b) => { - pass.extend_from_slice(&b); - frames.push(GraphicsFrame::Output(b)); + ClipboardSegment::Output(b) => { + let sniffed = graphics.sniff(&b); + process_graphics_output( + sniffed, + true, + &mut pass, + &mut frames, + &writer, + ); } - Segment::Query(reply) => { + ClipboardSegment::Event(ClipboardEvent::Reply( + reply, + )) => { if let Ok(mut w) = writer.lock() { let _ = w.write_all(&reply); let _ = w.flush(); } } - Segment::Image(img) => { - push_image_frame(&mut frames, img.encode_frame()); - } - Segment::ImageFromMedium(transfer) => { - // File/shm handoff on a local pane: - // read (and unlink) the object here, - // then forward the raw pixels exactly - // like an inline image. This is the - // fast path that skips the client-side - // inflate the compressed-inline `t=d` - // fallback would force. A failed read - // just drops the frame — the sender - // reclaims its own object. - if let Some(img) = transfer.resolve() { - push_image_frame( - &mut frames, - img.encode_frame(), + ClipboardSegment::Event(ClipboardEvent::Write( + write, + )) => { + if controller.is_some() { + frames.push(GraphicsFrame::ClipboardWrite( + write.encode_frame(), + )); + } else if let Ok(mut w) = writer.lock() { + let reply = crate::core::clipboard::response( + write.id.as_deref(), + "EBUSY", ); + let _ = w.write_all(&reply); + let _ = w.flush(); } } - Segment::Delete(d) => { - frames.push(GraphicsFrame::Delete(d.encode())); - } } } std::borrow::Cow::Owned(pass) @@ -1973,8 +2073,17 @@ impl DaemonPane { } pub fn attach(&self, subscriber: Sender) -> u64 { + self.attach_with_permissions(subscriber, false) + } + + pub fn attach_with_permissions( + &self, + subscriber: Sender, + allow_remote_clipboard_write: bool, + ) -> u64 { let mut st = self.state.lock().unwrap(); - let epoch = attach_subscriber(&mut st, subscriber); + let epoch = + attach_subscriber_with_permissions(&mut st, subscriber, allow_remote_clipboard_write); self.gate.reset(); epoch } @@ -1983,6 +2092,7 @@ impl DaemonPane { let mut st = self.state.lock().unwrap(); if st.subscriber_epoch == epoch { st.subscriber = None; + set_clipboard_permission(&mut st, false); self.gate.reset(); } !st.alive && st.subscriber.is_none() @@ -2475,8 +2585,25 @@ fn replay_state(st: &PaneState, subscriber: &Sender) { } } +#[cfg(test)] fn attach_subscriber(st: &mut PaneState, subscriber: Sender) -> u64 { + attach_subscriber_with_permissions(st, subscriber, false) +} + +/// The one place a pane's clipboard permission is decided. A pane that carries +/// its own spec keeps that spec's answer whatever the controller claims; every +/// other pane is exactly as permitted as the client controlling it says. +fn set_clipboard_permission(st: &mut PaneState, controller_says: bool) { + st.allow_remote_clipboard_write = st.clipboard_write_from_spec.unwrap_or(controller_says); +} + +fn attach_subscriber_with_permissions( + st: &mut PaneState, + subscriber: Sender, + allow_remote_clipboard_write: bool, +) -> u64 { st.subscriber_epoch += 1; + set_clipboard_permission(st, allow_remote_clipboard_write); replay_state(st, &subscriber); st.subscriber = Some(subscriber); st.subscriber_epoch @@ -3167,6 +3294,7 @@ mod tests { None, None, None, + false, || {}, ) .expect("spawn pane"); @@ -4224,6 +4352,8 @@ mod tests { ring: ReplayRing::new(ws(80, 24)), subscriber: None, subscriber_epoch: 0, + allow_remote_clipboard_write: false, + clipboard_write_from_spec: None, observers: Vec::new(), observer_seq: 0, shell_spec: None, @@ -5265,6 +5395,171 @@ mod tests { assert!(matches!(sub_rx.try_recv(), Ok(DaemonMsg::Output(b)) if b == b"after")); } + /// A window that reopens onto a native ssh pane it outlived attaches by + /// pane id: it never sees the profile that dialled the host, so it sends + /// `allow_remote_clipboard_write: false` because that is all it has. The + /// spec's answer has to survive that, or the permission the user granted + /// is revoked on the first re-attach and every later copy comes back + /// `EPERM` with the switch still showing "on". + #[test] + fn a_spec_granted_clipboard_permission_survives_a_re_attach() { + let mut st = test_state(true); + st.clipboard_write_from_spec = Some(true); + let (tx, _rx) = mpsc::channel(); + attach_subscriber_with_permissions(&mut st, tx, false); + assert!(st.allow_remote_clipboard_write); + + // And a profile that says no is not something an attaching client can + // talk its way past either. + let mut st = test_state(true); + st.clipboard_write_from_spec = Some(false); + let (tx, _rx) = mpsc::channel(); + attach_subscriber_with_permissions(&mut st, tx, true); + assert!(!st.allow_remote_clipboard_write); + + // A pane with no spec of its own — everything on a remote + // `tty7-server` — is exactly as permitted as its controller says. + let mut st = test_state(true); + let (tx, _rx) = mpsc::channel(); + attach_subscriber_with_permissions(&mut st, tx, true); + assert!(st.allow_remote_clipboard_write); + let (tx, _rx) = mpsc::channel(); + attach_subscriber_with_permissions(&mut st, tx, false); + assert!(!st.allow_remote_clipboard_write); + } + + #[test] + fn reader_strips_allowed_clipboard_images_and_only_notifies_the_controller() { + use base64::Engine as _; + + let state = Arc::new(Mutex::new(test_state(true))); + let (controller_tx, controller_rx) = mpsc::channel(); + let (observer_tx, observer_rx) = mpsc::channel(); + { + let mut st = state.lock().unwrap(); + attach_subscriber_with_permissions(&mut st, controller_tx, true); + observe_subscriber(&mut st, observer_tx, Arc::new(OutputGate::new())); + } + drain(&controller_rx); + drain(&observer_rx); + + let mime = base64::engine::general_purpose::STANDARD.encode("image/png"); + let data = base64::engine::general_purpose::STANDARD.encode(b"png-data"); + let stream = format!( + "before\x1b]5522;type=write:id=req-1\x1b\\\ + \x1b]5522;type=wdata:mime={mime};{data}\x1b\\\ + \x1b]5522;type=wdata\x1b\\after" + ); + DaemonPane::spawn_reader( + state.clone(), + Arc::new(AtomicBool::new(false)), + Arc::new(OutputGate::new()), + Box::new(std::io::Cursor::new(stream.into_bytes())), + null_writer(), + || false, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + cwd: Box::new(|| None), + }, + Arc::new(DeathReporter::new(|| {})), + ) + .join() + .unwrap(); + + assert_eq!(state.lock().unwrap().ring.flatten(), b"beforeafter"); + assert!(matches!( + controller_rx.try_recv(), + Ok(DaemonMsg::Output(bytes)) if bytes == b"before" + )); + let frame = match controller_rx.try_recv().unwrap() { + DaemonMsg::ClipboardWrite(frame) => frame, + other => panic!("expected ClipboardWrite, got {other:?}"), + }; + let write = crate::core::clipboard::ClipboardWrite::decode_frame(frame).unwrap(); + assert_eq!(write.mime, "image/png"); + assert_eq!(write.data, b"png-data"); + assert_eq!(write.id.as_deref(), Some("req-1")); + assert!(matches!( + controller_rx.try_recv(), + Ok(DaemonMsg::Output(bytes)) if bytes == b"after" + )); + + assert!(matches!( + observer_rx.try_recv(), + Ok(DaemonMsg::Output(bytes)) if bytes == b"before" + )); + assert!(matches!( + observer_rx.try_recv(), + Ok(DaemonMsg::Output(bytes)) if bytes == b"after" + )); + assert!( + !observer_rx + .try_iter() + .any(|msg| matches!(msg, DaemonMsg::ClipboardWrite(_))), + "an observer must never receive a clipboard side effect" + ); + } + + #[test] + fn reader_rejects_clipboard_images_when_permission_is_off() { + use base64::Engine as _; + + let state = Arc::new(Mutex::new(test_state(true))); + let (controller_tx, controller_rx) = mpsc::channel(); + attach_subscriber(&mut state.lock().unwrap(), controller_tx); + drain(&controller_rx); + + #[derive(Clone)] + struct SharedBuf(Arc>>); + impl Write for SharedBuf { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let sink = Arc::new(Mutex::new(Vec::new())); + let writer: Arc>> = + Arc::new(Mutex::new(Box::new(SharedBuf(sink.clone())))); + let mime = base64::engine::general_purpose::STANDARD.encode("image/png"); + let data = base64::engine::general_purpose::STANDARD.encode(b"png-data"); + let stream = format!( + "\x1b]5522;type=write:id=nope\x1b\\\ + \x1b]5522;type=wdata:mime={mime};{data}\x1b\\\ + \x1b]5522;type=wdata\x1b\\" + ); + DaemonPane::spawn_reader( + state.clone(), + Arc::new(AtomicBool::new(false)), + Arc::new(OutputGate::new()), + Box::new(std::io::Cursor::new(stream.into_bytes())), + writer, + || false, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + cwd: Box::new(|| None), + }, + Arc::new(DeathReporter::new(|| {})), + ) + .join() + .unwrap(); + + assert!(state.lock().unwrap().ring.flatten().is_empty()); + assert!( + !controller_rx + .try_iter() + .any(|msg| matches!(msg, DaemonMsg::ClipboardWrite(_))) + ); + assert_eq!( + *sink.lock().unwrap(), + crate::core::clipboard::response(Some("nope"), "EPERM") + ); + } + /// A kitty delete (`a=d`) is lifted out and forwarded as a `DeleteImage` /// selector, leaving the surrounding text intact in the ring. #[test] diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 19b3dc6b..ecdc62bd 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; pub const MAX_FRAME: usize = 64 * 1024 * 1024; -pub const PROTOCOL_VERSION: u32 = 5; +pub const PROTOCOL_VERSION: u32 = 6; pub const FEATURE_PANE_OWNER: &str = "pane-owner"; @@ -521,6 +521,8 @@ pub struct NativeSshSpec { #[serde(default = "default_true")] pub shell_integration: bool, #[serde(default)] + pub remote_clipboard_write: bool, + #[serde(default)] pub login_script: Vec, #[serde(default)] @@ -597,6 +599,7 @@ impl std::fmt::Debug for NativeSshSpec { .field("verify_host_keys", &self.verify_host_keys) .field("skip_banner", &self.skip_banner) .field("shell_integration", &self.shell_integration) + .field("remote_clipboard_write", &self.remote_clipboard_write) .field("login_script", &self.login_script) .field("display_name", &self.display_name) .field("profile_id", &self.profile_id) @@ -730,10 +733,12 @@ pub enum ClientMsg { owner: Option, workspace: Option, restore: Option, + allow_remote_clipboard_write: bool, }, Attach { pane_id: u64, size: WinSize, + allow_remote_clipboard_write: bool, }, Observe { pane_id: u64, @@ -831,6 +836,9 @@ pub enum DaemonMsg { /// ([`crate::core::kitty_graphics::ImageDelete::encode`]) telling the client /// which stored image(s)/placement(s) to drop. DeleteImage(Vec), + /// A completed OSC 5522 clipboard write. The daemon strips the control + /// sequence from replay; the GUI decides whether it may touch the clipboard. + ClipboardWrite(Vec), Cwd(PathBuf), Prompt { active: bool, @@ -933,6 +941,7 @@ mod kind { /// `DeleteImage` — a kitty graphics `a=d` delete lifted out of the stream /// (issue #213). Compact binary selector, like `IMAGE`. pub const DELETE_IMAGE: u8 = 61; + pub const CLIPBOARD_WRITE: u8 = 62; } pub fn write_frame(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> { @@ -1016,6 +1025,8 @@ struct OwnedSpawn { workspace: Option, #[serde(default)] restore: Option, + #[serde(default)] + allow_remote_clipboard_write: bool, } /// "This pane replaces one that died with the daemon." @@ -1049,6 +1060,7 @@ impl ClientMsg { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?), ClientMsg::Spawn { cwd, @@ -1057,6 +1069,7 @@ impl ClientMsg { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?), ClientMsg::Spawn { cwd, @@ -1065,6 +1078,7 @@ impl ClientMsg { owner, workspace, restore, + allow_remote_clipboard_write, } => write_frame( w, kind::SPAWN_OWNED, @@ -1075,11 +1089,18 @@ impl ClientMsg { owner: owner.clone(), workspace: workspace.clone(), restore: restore.clone(), + allow_remote_clipboard_write: *allow_remote_clipboard_write, })?, ), - ClientMsg::Attach { pane_id, size } => { - write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?) - } + ClientMsg::Attach { + pane_id, + size, + allow_remote_clipboard_write, + } => write_frame( + w, + kind::ATTACH, + &to_json(&(pane_id, size, allow_remote_clipboard_write))?, + ), ClientMsg::Observe { pane_id, size } => { write_frame(w, kind::OBSERVE, &to_json(&(pane_id, size))?) } @@ -1152,6 +1173,7 @@ impl ClientMsg { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } } kind::SPAWN_SHELL => { @@ -1163,6 +1185,7 @@ impl ClientMsg { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } } kind::SPAWN_OWNED => { @@ -1173,6 +1196,7 @@ impl ClientMsg { owner, workspace, restore, + allow_remote_clipboard_write, } = from_json(&payload)?; ClientMsg::Spawn { cwd, @@ -1181,11 +1205,16 @@ impl ClientMsg { owner, workspace, restore, + allow_remote_clipboard_write, } } kind::ATTACH => { - let (pane_id, size) = from_json(&payload)?; - ClientMsg::Attach { pane_id, size } + let (pane_id, size, allow_remote_clipboard_write) = from_json(&payload)?; + ClientMsg::Attach { + pane_id, + size, + allow_remote_clipboard_write, + } } kind::OBSERVE => { let (pane_id, size) = from_json(&payload)?; @@ -1281,6 +1310,7 @@ impl DaemonMsg { DaemonMsg::Output(bytes) => write_frame(w, kind::OUTPUT, bytes), DaemonMsg::Image(frame) => write_frame(w, kind::IMAGE, frame), DaemonMsg::DeleteImage(sel) => write_frame(w, kind::DELETE_IMAGE, sel), + DaemonMsg::ClipboardWrite(frame) => write_frame(w, kind::CLIPBOARD_WRITE, frame), DaemonMsg::Cwd(path) => write_frame(w, kind::CWD, &to_json(path)?), DaemonMsg::Prompt { active, @@ -1337,6 +1367,7 @@ impl DaemonMsg { kind::OUTPUT => DaemonMsg::Output(payload), kind::IMAGE => DaemonMsg::Image(payload), kind::DELETE_IMAGE => DaemonMsg::DeleteImage(payload), + kind::CLIPBOARD_WRITE => DaemonMsg::ClipboardWrite(payload), kind::CWD => DaemonMsg::Cwd(from_json(&payload)?), kind::PROMPT => { let (active, at_prompt, last_exit) = from_json(&payload)?; @@ -1419,6 +1450,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, }, ClientMsg::Resize(SIZE), ClientMsg::Input(vec![b'l', b's', b'\r']), @@ -1428,6 +1460,7 @@ mod tests { DaemonMsg::Spawned { pane_id: 9 }, DaemonMsg::Snapshot(vec![0x1b, b'[', b'2', b'J']), DaemonMsg::Output(b"hello\r\n".to_vec()), + DaemonMsg::ClipboardWrite(vec![1, 2, 3, 4]), DaemonMsg::Prompt { active: true, at_prompt: true, @@ -1474,6 +1507,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, }, ClientMsg::Spawn { cwd: None, @@ -1482,6 +1516,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, }, ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), @@ -1494,6 +1529,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, }, ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), @@ -1502,6 +1538,7 @@ mod tests { owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()), workspace: None, restore: None, + allow_remote_clipboard_write: false, }, ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), @@ -1510,6 +1547,7 @@ mod tests { owner: None, workspace: Some("ws-main".into()), restore: None, + allow_remote_clipboard_write: true, }, ClientMsg::Observe { pane_id: 42, @@ -1518,6 +1556,7 @@ mod tests { ClientMsg::Attach { pane_id: 42, size: SIZE, + allow_remote_clipboard_write: true, }, ClientMsg::Input(vec![0x1b, b'[', b'A', 0, 255]), ClientMsg::SendInput { @@ -1831,6 +1870,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1851,6 +1891,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } ); } @@ -1869,6 +1910,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1884,6 +1926,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } ); } @@ -1901,6 +1944,7 @@ mod tests { owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()), workspace: Some("ws-7".into()), restore: None, + allow_remote_clipboard_write: true, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1931,6 +1975,7 @@ mod tests { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } ); } @@ -1944,6 +1989,7 @@ mod tests { owner: None, workspace: Some("ws-main".into()), restore: None, + allow_remote_clipboard_write: true, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -2137,6 +2183,7 @@ mod tests { verify_host_keys: true, skip_banner: false, shell_integration: true, + remote_clipboard_write: false, login_script: vec![], display_name: None, profile_id: None, @@ -2161,6 +2208,7 @@ mod tests { verify_host_keys: true, skip_banner: false, shell_integration: true, + remote_clipboard_write: true, login_script: vec!["tmux attach".into()], display_name: Some("prod-web".into()), profile_id: Some("uuid-1".into()), @@ -2458,7 +2506,7 @@ mod tests { #[test] fn the_local_daemon_does_not_claim_the_control_dialect() { let v = DaemonVersion::current(); - assert_eq!(v.protocol, 5); + assert_eq!(v.protocol, 6); assert!( !v.has_feature(crate::daemon::control::feature::CONTROL), "the session daemon must not advertise a dialect it cannot serve" diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index c48b7142..aca6d6d8 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -701,6 +701,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { owner, workspace, restore, + allow_remote_clipboard_write, } => { let id = registry.alloc_id(); if let Some(dead) = restore.as_ref().map(|r| r.pane_id) { @@ -721,28 +722,45 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { .ok(); } }; - let pane = - match DaemonPane::spawn(id, cwd, size, shell, owner, workspace, restore, on_dead) { - Ok(p) => p, - Err(e) => { - let mut w = write_stream; - // The daemon's own error is already a sentence; a second - // "spawn failed:" in front of it only pads the one the - // window ends up showing. - let _ = DaemonMsg::Error(format!("{e}")).encode(&mut w); - return Err(e); - } - }; + let pane = match DaemonPane::spawn( + id, + cwd, + size, + shell, + owner, + workspace, + restore, + allow_remote_clipboard_write, + on_dead, + ) { + Ok(p) => p, + Err(e) => { + let mut w = write_stream; + // The daemon's own error is already a sentence; a second + // "spawn failed:" in front of it only pads the one the + // window ends up showing. + let _ = DaemonMsg::Error(format!("{e}")).encode(&mut w); + return Err(e); + } + }; registry.insert(pane.clone()); { let mut w = &write_stream; DaemonMsg::Spawned { pane_id: id }.encode(&mut w)?; } - stream_pane(pane, id, read_stream, write_stream, registry) + stream_pane( + pane, + id, + read_stream, + write_stream, + registry, + allow_remote_clipboard_write, + ) } ClientMsg::SpawnNativeSsh { cwd: _, size, spec } => { + let allow_remote_clipboard_write = spec.remote_clipboard_write; let id = registry.alloc_id(); let on_dead = { let registry = registry.clone(); @@ -769,13 +787,29 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { let mut w = &write_stream; DaemonMsg::Spawned { pane_id: id }.encode(&mut w)?; } - stream_pane(pane, id, read_stream, write_stream, registry) + stream_pane( + pane, + id, + read_stream, + write_stream, + registry, + allow_remote_clipboard_write, + ) } - ClientMsg::Attach { pane_id, size: _ } => match registry.get(pane_id) { - Some(pane) => { - stream_pane_with_attach(pane, pane_id, read_stream, write_stream, registry) - } + ClientMsg::Attach { + pane_id, + size: _, + allow_remote_clipboard_write, + } => match registry.get(pane_id) { + Some(pane) => stream_pane_with_attach( + pane, + pane_id, + read_stream, + write_stream, + registry, + allow_remote_clipboard_write, + ), None => { let mut w = write_stream; DaemonMsg::Error(format!("no such pane {pane_id}")).encode(&mut w)?; @@ -1041,9 +1075,10 @@ fn stream_pane_with_attach( read_stream: Stream, write_stream: Stream, registry: Arc, + allow_remote_clipboard_write: bool, ) -> anyhow::Result<()> { let (tx, rx) = mpsc::channel::(); - let epoch = pane.attach(tx); + let epoch = pane.attach_with_permissions(tx, allow_remote_clipboard_write); run_stream(pane, id, epoch, rx, read_stream, write_stream, registry) } @@ -1053,9 +1088,10 @@ fn stream_pane( read_stream: Stream, write_stream: Stream, registry: Arc, + allow_remote_clipboard_write: bool, ) -> anyhow::Result<()> { let (tx, rx) = mpsc::channel::(); - let epoch = pane.attach(tx); + let epoch = pane.attach_with_permissions(tx, allow_remote_clipboard_write); run_stream(pane, id, epoch, rx, read_stream, write_stream, registry) } @@ -1219,6 +1255,7 @@ fn spawn_writer( // credits, so they must debit it too or the reader stays // throttled against bytes that already left the queue. DaemonMsg::Image(b) => b.len(), + DaemonMsg::ClipboardWrite(b) => b.len(), _ => 0, }; let write_ok = msg.encode(&mut write_stream).is_ok(); @@ -1387,6 +1424,7 @@ mod tests { ClientMsg::Attach { pane_id: 999, size: SIZE, + allow_remote_clipboard_write: false, } .encode(&mut client) .unwrap(); diff --git a/crates/tty7-core/src/daemon/ssh/test_support.rs b/crates/tty7-core/src/daemon/ssh/test_support.rs index 70cc2ceb..946bd928 100644 --- a/crates/tty7-core/src/daemon/ssh/test_support.rs +++ b/crates/tty7-core/src/daemon/ssh/test_support.rs @@ -219,6 +219,7 @@ pub(crate) fn base_spec() -> NativeSshSpec { verify_host_keys: true, skip_banner: false, shell_integration: true, + remote_clipboard_write: false, login_script: vec![], display_name: None, profile_id: None, diff --git a/crates/tty7-server/tests/routed_pane.rs b/crates/tty7-server/tests/routed_pane.rs index e0676e83..526657e0 100644 --- a/crates/tty7-server/tests/routed_pane.rs +++ b/crates/tty7-server/tests/routed_pane.rs @@ -111,6 +111,7 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } .encode(&mut sock) .unwrap(); @@ -133,6 +134,7 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { ClientMsg::Attach { pane_id, size: win(), + allow_remote_clipboard_write: false, } .encode(&mut back) .unwrap(); @@ -203,6 +205,7 @@ fn a_routed_kill_reaches_the_pane_it_names() { owner: None, workspace: None, restore: None, + allow_remote_clipboard_write: false, } .encode(&mut sock) .unwrap(); diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs index 04436e72..22ae262a 100644 --- a/crates/tty7-server/tests/scrollback_restore.rs +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -182,6 +182,7 @@ impl Instance { pane_id: dead, banner: Some("the shell below is new".to_string()), }), + allow_remote_clipboard_write: false, } .encode(&mut stream) .expect("send Spawn"); diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index efdbbed5..75d593e8 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -166,6 +166,12 @@ angle brackets, and a tab: | `verify_host_keys` | bool | `true` | | | `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. | +Each object in `ssh_profiles` can also set: + +| Key | Type | Default | | +|---|---|---|---| +| `remote_clipboard_write` | bool | `false` | Allow programs on that SSH host to write PNG, JPEG, GIF, or WebP images to this machine's clipboard with OSC 5522. | + ## Updates and network | Key | Type | Default | | diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx index af5d7fe8..ea42d507 100644 --- a/docs/remote/ssh.mdx +++ b/docs/remote/ssh.mdx @@ -87,10 +87,95 @@ Behind **Advanced** on a profile, grouped: | **Algorithms** | KEX algorithms, ciphers, MACs, host-key algorithms, compression | | **Connection** | Keepalive interval and count, connect timeout, X11 forwarding | | **Session** | Shell integration, login scripts, skip banner | +| **Security** | Host-key verification, remote clipboard image writes | Everything blank means "the library default", so you only fill in what you actually need to override. +## Copying a remote image to this machine + +Programs on an SSH host can write PNG, JPEG, GIF, or WebP images to the system +clipboard on the machine running tty7 with the OSC 5522 clipboard protocol. +Enable **Advanced → Security → Remote clipboard images** for that saved host +first. It is off by default because any program that writes terminal output +would otherwise be able to replace the clipboard. + +This Python script can be installed on the remote host as +`tty7-copy-image`: + +```python +#!/usr/bin/env python3 +import base64 +import os +import pathlib +import re +import select +import secrets +import sys +import termios +import time +import tty + +path = pathlib.Path(sys.argv[1]) +mime = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", +}.get(path.suffix.lower()) +if mime is None: + raise SystemExit("supported formats: png, jpg, jpeg, gif, webp") + +data = path.read_bytes() +if len(data) > 16 * 1024 * 1024: + raise SystemExit("image exceeds tty7's 16 MiB clipboard limit") + +osc, st = b"\x1b]5522;", b"\x1b\\" +encoded_mime = base64.b64encode(mime.encode()) +request_id = secrets.token_hex(8) +out = sys.stdout.buffer +fd = sys.stdin.fileno() +old = termios.tcgetattr(fd) +status = None +try: + tty.setraw(fd) + rid = request_id.encode() + out.write(osc + b"type=write:id=" + rid + st) + for offset in range(0, len(data), 4096): + chunk = base64.b64encode(data[offset:offset + 4096]) + out.write( + osc + b"type=wdata:id=" + rid + b":mime=" + encoded_mime + b";" + chunk + st + ) + out.write(osc + b"type=wdata:id=" + rid + st) + out.flush() + + reply = bytearray() + pattern = re.compile( + rb"\x1b\]5522;type=write:status=([A-Z]+):id=" + rid + rb"\x1b\\" + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], deadline - time.monotonic()) + if not ready: + break + reply.extend(os.read(fd, 4096)) + match = pattern.search(reply) + if match: + status = match.group(1).decode() + break +finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + +if status != "DONE": + raise SystemExit(f"clipboard write failed: {status or 'timeout'}") +``` + +Run `tty7-copy-image screenshot.png`. A compliant sender may include an OSC +5522 request id and wait for tty7's `DONE`, `EPERM`, `EINVAL`, or `ENOSYS` +response. Clipboard control packets are not retained in scrollback and are not +replayed after reconnecting. + ## Authentication prompts Password, key passphrase, and 2FA prompts appear as sheets inside the pane, with diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index df9396d6..86d517dc 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -91,6 +91,8 @@ struct ReaderSignals { /// anchored to the grid for the paint path to blit. Shared with the reader, /// which places/deletes them as `DaemonMsg::Image`/`DeleteImage` frames land. images: crate::terminal::images::ImageStore, + clipboard_writes: Arc>>, + clipboard_write_busy: Arc, /// Where each agent turn started, anchored to the grid the same way — see /// [`crate::terminal::agent_marks`]. The daemon reads the same events for /// the status dot, but only the client holds the rows they point into. @@ -185,6 +187,16 @@ impl PaneRoute { pub fn is_local(&self) -> bool { matches!(self, PaneRoute::Local) } + + fn allow_remote_clipboard_write(&self) -> bool { + match self { + PaneRoute::Remote { header, .. } => match &header.target { + crate::daemon::router::RouteTarget::Ssh(spec) => spec.remote_clipboard_write, + _ => false, + }, + PaneRoute::Local | PaneRoute::Unroutable(_) => false, + } + } } /// How much unsent input a pane holds before it calls the link lost. A link @@ -512,6 +524,8 @@ pub struct RemoteTerminal { /// frames, read by the paint path — only the client holds the grid the /// anchors are relative to, so the store lives here rather than in the daemon. images: crate::terminal::images::ImageStore, + clipboard_writes: Arc>>, + clipboard_write_busy: Arc, /// The conversation's shape, for the outline in the Info panel: one entry /// per agent turn, anchored to the scrollback row it began on. turns: AgentTurns, @@ -532,7 +546,7 @@ pub struct RemoteTerminal { /// CLI running in it can use workspace-scoped verbs with no argument. /// /// This is the same id as `owner`, but decided separately: `owner` is also -/// gated on FEATURE_PANE_OWNER, while the workspace field rides the c4p5 spawn +/// gated on FEATURE_PANE_OWNER, while the workspace field rides the owned spawn /// kind and needs no feature probe. Local routes only — a remote server keeps /// its own machine tree, and this id names a workspace in ours. fn spawn_workspace(owner: Option<&str>, route: &PaneRoute) -> Option { @@ -663,6 +677,7 @@ impl RemoteTerminal { owner, workspace, restore, + allow_remote_clipboard_write: route.allow_remote_clipboard_write(), } .encode(&mut stream)?; let pane_id = match spawn_reply(&mut stream, attach_reply_wait(route), "Spawn")? { @@ -711,7 +726,12 @@ impl RemoteTerminal { let mut stream = connect_routed(route)?; let win = win_size(size, cell_w, cell_h); - ClientMsg::Attach { pane_id, size: win }.encode(&mut stream)?; + ClientMsg::Attach { + pane_id, + size: win, + allow_remote_clipboard_write: route.allow_remote_clipboard_write(), + } + .encode(&mut stream)?; let buffered = match attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route)) { Ok(buffered) => buffered, Err(e) if route.is_local() && attach_unanswered(&e) => { @@ -764,6 +784,7 @@ impl RemoteTerminal { ClientMsg::Attach { pane_id, size: win_size(size, cell_w, cell_h), + allow_remote_clipboard_write: route.allow_remote_clipboard_write(), } .encode(&mut stream)?; let buffered = attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route))?; @@ -781,6 +802,10 @@ impl RemoteTerminal { ) -> anyhow::Result<()> { self.stop_reader(); while self.events.try_recv().is_ok() {} + if let Ok(mut pending) = self.clipboard_writes.lock() { + pending.clear(); + } + self.clipboard_write_busy.store(false, Ordering::Release); let read_half = stream.try_clone()?; @@ -821,6 +846,8 @@ impl RemoteTerminal { auth: self.auth_prompts.clone(), phase: self.ssh_phase.clone(), images: self.images.clone(), + clipboard_writes: self.clipboard_writes.clone(), + clipboard_write_busy: self.clipboard_write_busy.clone(), turns: self.turns.clone(), }, ); @@ -873,6 +900,8 @@ impl RemoteTerminal { Arc::new(Mutex::new(VecDeque::new())); let ssh_phase: Arc>> = Arc::new(Mutex::new(None)); let images = crate::terminal::images::ImageStore::new(); + let clipboard_writes = Arc::new(Mutex::new(VecDeque::new())); + let clipboard_write_busy = Arc::new(AtomicBool::new(false)); let turns = AgentTurns::new(); let reader_quit = Arc::new(AtomicBool::new(false)); @@ -896,6 +925,8 @@ impl RemoteTerminal { auth: auth_prompts.clone(), phase: ssh_phase.clone(), images: images.clone(), + clipboard_writes: clipboard_writes.clone(), + clipboard_write_busy: clipboard_write_busy.clone(), turns: turns.clone(), }, ); @@ -930,6 +961,8 @@ impl RemoteTerminal { agent, agent_session, images, + clipboard_writes, + clipboard_write_busy, turns, route: PaneRoute::Local, proxy, @@ -1013,6 +1046,8 @@ impl RemoteTerminal { auth, phase, images, + clipboard_writes, + clipboard_write_busy, turns, } = signals; crate::core::threads::promote_to_user_interactive(); @@ -1329,6 +1364,40 @@ impl RemoteTerminal { proxy.send_event(AlacEvent::Wakeup); } } + DaemonMsg::ClipboardWrite(frame) => { + flush_batch!(); + if let Some(write) = + tty7_core::core::clipboard::ClipboardWrite::decode_frame(frame) + { + if clipboard_write_busy + .compare_exchange( + false, + true, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + if let Ok(mut pending) = clipboard_writes.lock() { + pending.push_back(write); + proxy.send_event(AlacEvent::Wakeup); + } else { + clipboard_write_busy.store(false, Ordering::Release); + } + } else { + let reply = tty7_core::core::clipboard::response( + write.id.as_deref(), + "EBUSY", + ); + proxy.send_event(AlacEvent::PtyWrite( + String::from_utf8(reply) + .expect("OSC 5522 replies are ASCII"), + )); + } + } else { + clipboard_write_busy.store(false, Ordering::Release); + } + } DaemonMsg::Cwd(path) => { flush_batch!(); if let Ok(mut guard) = cwd.lock() { @@ -1605,6 +1674,17 @@ impl RemoteTerminal { self.images.clone() } + pub fn pop_clipboard_write(&self) -> Option { + self.clipboard_writes + .lock() + .ok() + .and_then(|mut pending| pending.pop_front()) + } + + pub fn finish_clipboard_write(&self) { + self.clipboard_write_busy.store(false, Ordering::Release); + } + pub fn agent_session(&self) -> Option { self.agent_session.lock().ok().and_then(|g| g.clone()) } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 6ec44772..293da4d1 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -232,6 +232,8 @@ pub struct TerminalView { /// been prepared for this pane. `None` means "not prepared yet", never /// "preparation failed" — see [`staging_cache`]. remote_clipboard_dir: Option, + remote_clipboard_write_in_flight: Option, + remote_clipboard_write_generation: u64, pub focus_handle: FocusHandle, /// See [`displayed_registry`]. Shared with the registry so the app can /// flip it during a draw without an entity access. @@ -765,6 +767,23 @@ fn remote_paste_spec<'a>( ssh_spec } +/// Whether this pane may hand a remote program's image to the system clipboard. +/// +/// The daemon is the gate. It holds the `NativeSshSpec` that dialled the host +/// and answers a write the profile forbids with `EPERM` before a byte of image +/// reaches this process; a pane it never granted the permission to sends no +/// `ClipboardWrite` at all. This is a second opinion, and it can only give one +/// when the pane kept a copy of that spec. A pane restored by attaching to its +/// id did not keep one — reading that absence as "forbidden" is what put the +/// permission to sleep on the first restart after the user granted it. So: +/// refuse what this side can see is forbidden, and defer otherwise. +fn allows_remote_clipboard_write( + workspace: Option<&crate::terminal::PaneWorkspace>, + ssh_spec: Option<&crate::daemon::protocol::NativeSshSpec>, +) -> bool { + remote_paste_spec(workspace, ssh_spec).is_none_or(|spec| spec.remote_clipboard_write) +} + /// Whether a pane stages the clipboard image to a file instead of forwarding /// SYN and letting the agent read the clipboard itself. /// @@ -972,6 +991,34 @@ fn transcode_to_png(bytes: &[u8], format: gpui::ImageFormat) -> Option> Some(out) } +fn validate_remote_clipboard_image( + write: tty7_core::core::clipboard::ClipboardWrite, +) -> Result<(gpui::Image, Option), String> { + let (gpui_format, image_format) = match write.mime.as_str() { + "image/png" => (gpui::ImageFormat::Png, image::ImageFormat::Png), + "image/jpeg" | "image/jpg" => (gpui::ImageFormat::Jpeg, image::ImageFormat::Jpeg), + "image/gif" => (gpui::ImageFormat::Gif, image::ImageFormat::Gif), + "image/webp" => (gpui::ImageFormat::Webp, image::ImageFormat::WebP), + _ => return Err("unsupported image MIME type".into()), + }; + if image::guess_format(&write.data).ok() != Some(image_format) { + return Err("image signature does not match its MIME type".into()); + } + + let mut reader = + image::ImageReader::with_format(std::io::Cursor::new(&write.data), image_format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some(256 << 20); + reader.limits(limits); + reader + .decode() + .map_err(|e| format!("invalid or oversized image: {e}"))?; + + Ok((gpui::Image::from_bytes(gpui_format, write.data), write.id)) +} + fn fallback_chain(family: &str, configured: &[String]) -> Vec { let mut chain = configured.to_vec(); let mut pin = |name: &str| { @@ -1282,6 +1329,8 @@ impl TerminalView { restored: false, ssh_spec: None, remote_clipboard_dir: None, + remote_clipboard_write_in_flight: None, + remote_clipboard_write_generation: 0, focus_handle, displayed, font, @@ -1526,6 +1575,9 @@ impl TerminalView { cell_h: u16, cx: &mut Context, ) -> anyhow::Result<()> { + self.remote_clipboard_write_generation = + self.remote_clipboard_write_generation.wrapping_add(1); + self.remote_clipboard_write_in_flight = None; self.terminal .adopt_relink(stream, buffered, route, size, cell_w, cell_h)?; self.relink_abandoned = false; @@ -1806,9 +1858,62 @@ impl TerminalView { } } + fn poll_remote_clipboard_write(&mut self, cx: &mut Context) { + if self.remote_clipboard_write_in_flight.is_some() { + return; + } + let write = loop { + let Some(write) = self.terminal.pop_clipboard_write() else { + return; + }; + if allows_remote_clipboard_write(self.workspace.as_ref(), self.ssh_spec.as_deref()) { + break write; + } + self.terminal.finish_clipboard_write(); + self.terminal.write(tty7_core::core::clipboard::response( + write.id.as_deref(), + "EPERM", + )); + }; + + let generation = self.remote_clipboard_write_generation; + self.remote_clipboard_write_in_flight = Some(generation); + let request_id = write.id.clone(); + cx.spawn(async move |view, cx| { + let validated = cx + .background_spawn(async move { validate_remote_clipboard_image(write) }) + .await; + view.update(cx, |view, cx| { + if view.remote_clipboard_write_in_flight != Some(generation) { + return; + } + view.remote_clipboard_write_in_flight = None; + view.terminal.finish_clipboard_write(); + match validated { + Ok((image, id)) => { + cx.write_to_clipboard(ClipboardItem::new_image(&image)); + view.terminal + .write(tty7_core::core::clipboard::response(id.as_deref(), "DONE")); + } + Err(reason) => { + log::warn!("refusing remote clipboard image: {reason}"); + view.terminal.write(tty7_core::core::clipboard::response( + request_id.as_deref(), + "EINVAL", + )); + } + } + view.poll_remote_clipboard_write(cx); + }) + .ok(); + }) + .detach(); + } + fn handle_event(&mut self, ev: AlacEvent, cx: &mut Context) { self.terminal.poll_exited(); self.sync_typeahead_owner(); + self.poll_remote_clipboard_write(cx); if self.terminal.has_pending_auth() { cx.emit(AuthPromptReady); } @@ -7573,6 +7678,44 @@ mod tests { ); } + /// A pane restored by attaching to its id carries no copy of the spec that + /// dialled the host. The daemon still has one, and still refuses a write + /// the profile forbids — so a missing copy here is not a verdict. + #[test] + fn a_pane_without_its_own_spec_defers_to_the_daemons_verdict() { + let mut spec: crate::daemon::protocol::NativeSshSpec = serde_json::from_str( + r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#, + ) + .unwrap(); + assert!(!super::allows_remote_clipboard_write(None, Some(&spec))); + spec.remote_clipboard_write = true; + assert!(super::allows_remote_clipboard_write(None, Some(&spec))); + assert!(super::allows_remote_clipboard_write(None, None)); + } + + #[test] + fn remote_clipboard_images_must_match_their_declared_mime() { + let pixel = image::RgbaImage::from_pixel(1, 1, image::Rgba([4, 5, 6, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(pixel) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + + let valid = tty7_core::core::clipboard::ClipboardWrite { + mime: "image/png".into(), + data: png.clone(), + id: None, + }; + assert!(super::validate_remote_clipboard_image(valid).is_ok()); + + let mismatched = tty7_core::core::clipboard::ClipboardWrite { + mime: "image/jpeg".into(), + data: png, + id: None, + }; + assert!(super::validate_remote_clipboard_image(mismatched).is_err()); + } + #[test] fn a_wsl_pane_gets_the_automount_path_not_the_windows_one() { // The staged file really is on the pane's own disk — only its name @@ -9648,6 +9791,109 @@ mod gpui_tests { } } + #[gpui::test] + fn allowed_remote_clipboard_image_reaches_the_system_clipboard(cx: &mut TestAppContext) { + use gpui::ClipboardEntry; + + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, _, _| { + let mut spec: crate::daemon::protocol::NativeSshSpec = serde_json::from_str( + r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#, + ) + .unwrap(); + spec.remote_clipboard_write = true; + view.ssh_spec = Some(Box::new(spec)); + }) + .unwrap(); + + let pixel = image::RgbaImage::from_pixel(1, 1, image::Rgba([4, 5, 6, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(pixel) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let write = tty7_core::core::clipboard::ClipboardWrite { + mime: "image/png".into(), + data: png.clone(), + id: Some("copy-1".into()), + }; + DaemonMsg::ClipboardWrite(write.encode_frame()) + .encode(&mut daemon) + .unwrap(); + + for _ in 0..400 { + cx.run_until_parked(); + let copied = cx.update(|cx| { + cx.read_from_clipboard().and_then(|item| { + item.entries().iter().find_map(|entry| match entry { + ClipboardEntry::Image(image) => Some(image.bytes.clone()), + _ => None, + }) + }) + }); + if copied.as_deref() == Some(png.as_slice()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let copied = cx.update(|cx| cx.read_from_clipboard()); + assert!(copied.is_some_and(|item| { + item.entries() + .iter() + .any(|entry| matches!(entry, ClipboardEntry::Image(image) if image.bytes == png)) + })); + assert_eq!( + next_input(&mut daemon), + tty7_core::core::clipboard::response(Some("copy-1"), "DONE") + ); + } + + #[gpui::test] + fn disabled_remote_clipboard_image_is_rejected_without_overwriting(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, _, _| { + view.ssh_spec = Some(Box::new( + serde_json::from_str( + r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#, + ) + .unwrap(), + )); + }) + .unwrap(); + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("keep me".into()))); + + let write = tty7_core::core::clipboard::ClipboardWrite { + mime: "image/png".into(), + data: vec![1, 2, 3], + id: Some("copy-2".into()), + }; + DaemonMsg::ClipboardWrite(write.encode_frame()) + .encode(&mut daemon) + .unwrap(); + let mut reply = None; + for _ in 0..100 { + cx.run_until_parked(); + reply = next_input_until_timeout(&mut daemon); + if reply.is_some() { + break; + } + } + + assert_eq!( + reply, + Some(tty7_core::core::clipboard::response( + Some("copy-2"), + "EPERM" + )) + ); + assert_eq!( + cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text())) + .as_deref(), + Some("keep me") + ); + } + #[gpui::test] fn ctrl_l_at_prompt_reaches_the_shell(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 78824799..b1cf3fc6 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -303,6 +303,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsGroupConnection => "Connection", L10nKey::SettingsGroupSession => "Session", L10nKey::SettingsGroupSecurity => "Security", + L10nKey::SettingsRemoteClipboardWrite => "Remote clipboard images", + L10nKey::SettingsRemoteClipboardWriteDesc => { + "Allow programs on this host to replace the local clipboard with images over OSC 5522." + } L10nKey::SettingsIdentityFiles => "Identity files", L10nKey::SettingsIdentityFilesDesc => "Private-key paths, one per line (%h/%r expand).", L10nKey::SettingsAgentForwarding => "Agent forwarding", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 1096f62e..d9e4cf07 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -308,6 +308,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsGroupConnection => "接続", L10nKey::SettingsGroupSession => "セッション", L10nKey::SettingsGroupSecurity => "セキュリティ", + L10nKey::SettingsRemoteClipboardWrite => "リモートのクリップボード画像", + L10nKey::SettingsRemoteClipboardWriteDesc => { + "このホスト上のプログラムが OSC 5522 でローカルクリップボードを画像に置き換えることを許可します" + } L10nKey::SettingsIdentityFiles => "秘密鍵ファイル", L10nKey::SettingsIdentityFilesDesc => "秘密鍵のパス(1 行に 1 つ。%h/%r は展開されます)", L10nKey::SettingsAgentForwarding => "エージェント転送", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 14ceb8eb..62e8c063 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -293,6 +293,8 @@ l10n_keys! { SettingsGroupConnection, SettingsGroupSession, SettingsGroupSecurity, + SettingsRemoteClipboardWrite, + SettingsRemoteClipboardWriteDesc, SettingsIdentityFiles, SettingsIdentityFilesDesc, SettingsAgentForwarding, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 52834c77..e942e5b7 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -270,6 +270,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsGroupConnection => "连接", L10nKey::SettingsGroupSession => "会话", L10nKey::SettingsGroupSecurity => "安全", + L10nKey::SettingsRemoteClipboardWrite => "远端剪贴板图片", + L10nKey::SettingsRemoteClipboardWriteDesc => { + "允许此主机上的程序通过 OSC 5522 用图片覆盖本机剪贴板。" + } L10nKey::SettingsIdentityFiles => "身份文件", L10nKey::SettingsIdentityFilesDesc => "私钥路径,每行一个(支持 %h/%r 展开)。", L10nKey::SettingsAgentForwarding => "ssh-agent 转发", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 4a802f3e..d64b0cd6 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -980,6 +980,7 @@ pub(crate) struct SshProfileForm { x11: bool, skip_banner: bool, shell_integration: bool, + remote_clipboard_write: bool, verify_host_keys: Option, warn_on_close: Option, @@ -1226,6 +1227,7 @@ pub(crate) struct SshFormDraft { warn_on_close: Option, skip_banner: bool, shell_integration: bool, + remote_clipboard_write: bool, login_scripts: String, x11: bool, kex: String, @@ -1323,6 +1325,7 @@ fn validate_ssh_draft(draft: SshFormDraft, profiles: &[SshProfile]) -> (SshProfi warn_on_close: draft.warn_on_close, skip_banner: draft.skip_banner, shell_integration: draft.shell_integration, + remote_clipboard_write: draft.remote_clipboard_write, login_scripts: split_lines(&draft.login_scripts), x11: draft.x11, algorithms: Algorithms { @@ -3684,6 +3687,7 @@ impl Tty7App { x11: profile.x11, skip_banner: profile.skip_banner, shell_integration: profile.shell_integration, + remote_clipboard_write: profile.remote_clipboard_write, verify_host_keys: profile.verify_host_keys, warn_on_close: profile.warn_on_close, test: None, @@ -3729,6 +3733,7 @@ impl Tty7App { warn_on_close: form.warn_on_close, skip_banner: form.skip_banner, shell_integration: form.shell_integration, + remote_clipboard_write: form.remote_clipboard_write, login_scripts: raw(&form.login_scripts), x11: form.x11, kex: raw(&form.kex), @@ -4993,6 +4998,22 @@ impl Tty7App { ), ) .child(self.subgroup_header(L10nKey::SettingsGroupSecurity, cx)) + .child( + self.settings_row( + t(L10nKey::SettingsRemoteClipboardWrite), + t(L10nKey::SettingsRemoteClipboardWriteDesc), + crate::ui::theme::switch("ssh-form-remote-clipboard-write", cx) + .checked(form.remote_clipboard_write) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + if let Some(f) = this.ssh_form_mut() { + f.remote_clipboard_write = *on; + cx.notify(); + } + })) + .into_any_element(), + cx, + ), + ) .child(self.settings_row( t(L10nKey::SettingsVerifyHostKeys), t_fmt( diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 60816e37..adc03a3d 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -395,6 +395,7 @@ fn build_spec_inner( verify_host_keys: profile.verify_host_keys.unwrap_or(global_verify_host_keys), skip_banner: profile.skip_banner, shell_integration: profile.shell_integration, + remote_clipboard_write: profile.remote_clipboard_write, login_script: profile.login_scripts.clone(), // What the pane calls itself before the remote shell says anything. // A nameless profile — every host imported from `~/.ssh/config` is @@ -443,6 +444,7 @@ pub(crate) fn profile_from_live_spec(spec: &NativeSshSpec) -> SshProfile { profile.connect_timeout_s = spec.connect_timeout_s; profile.skip_banner = spec.skip_banner; profile.shell_integration = spec.shell_integration; + profile.remote_clipboard_write = spec.remote_clipboard_write; profile.login_scripts = spec.login_script.clone(); profile.x11 = spec.x11; profile.algorithms = Algorithms { @@ -692,6 +694,7 @@ mod tests { p.agent_forward = true; p.x11 = true; p.skip_banner = true; + p.remote_clipboard_write = true; p.socks_proxy = Some(HostPort::new("127.0.0.1", 1080)); p.keepalive_interval_s = Some(30); p.connect_timeout_s = Some(9); @@ -712,7 +715,7 @@ mod tests { assert_eq!(back.user, p.user); assert_eq!(back.auth, p.auth); assert_eq!(back.identity_files, p.identity_files); - assert!(back.agent_forward && back.x11 && back.skip_banner); + assert!(back.agent_forward && back.x11 && back.skip_banner && back.remote_clipboard_write); assert_eq!(back.socks_proxy, p.socks_proxy); assert_eq!(back.keepalive_interval_s, p.keepalive_interval_s); assert_eq!(back.connect_timeout_s, p.connect_timeout_s);