feat(ssh): allow remote image clipboard writes (#766)

* feat(ssh): allow remote image clipboard writes

* fix(ssh): keep a profile's clipboard grant across a re-attach

A native ssh pane's OSC 5522 permission is decided by the spec that
dialled the host, and the daemon is the only side that holds it. A window
reopening onto a pane that outlived it attaches by pane id, has no spec
to read, and sends `allow_remote_clipboard_write: false` — which the
daemon took as the new answer and the pane's own view took as a refusal.
Both sides then said no, so the first restart after switching the
permission on turned every copy into an `EPERM` with the switch still
reading "on".

Pin the spec's answer in the pane and route both attach and detach
through one decision point, so a pane that carries a spec keeps that
spec's answer whatever an attaching client claims, and a pane without one
— everything on a remote `tty7-server` — is exactly as permitted as its
controller says. On the client side, refuse only what the pane can see is
forbidden and leave the verdict to the daemon otherwise.

Also: release a failed transfer's buffered bytes instead of parking up to
`MAX_CLIPBOARD_BYTES` per pane until the next request, and answer the
capability probe with the permission actually in force rather than a
constant that always reads as "off".

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
ayamir
2026-09-02 14:12:28 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 0b6c332618
commit e231b16fb3
22 changed files with 1698 additions and 67 deletions
+7
View File
@@ -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
+7 -1
View File
@@ -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<PaneSession> {
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)
}
+769
View File
@@ -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<u8>,
pub id: Option<String>,
}
impl ClipboardWrite {
pub fn encode_frame(self) -> Vec<u8> {
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<u8>) -> Option<Self> {
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<u8>),
}
#[derive(Default)]
struct WriteState {
id: Option<String>,
mime: Option<String>,
data: Vec<u8>,
failed: bool,
}
#[derive(Default)]
struct Parser {
write: Option<WriteState>,
allowed: bool,
available: bool,
}
impl Parser {
fn feed(&mut self, payload: &[u8]) -> Option<Event> {
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::<Vec<_>>();
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<Event> {
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<Event> {
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<u8> {
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<u8> {
response_for("write", id, status)
}
fn response_for(kind: &str, id: Option<&str>, status: &str) -> Vec<u8> {
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<u64>,
}
impl ClipboardSniffer {
pub fn set_controller(&mut self, epoch: Option<u64>, 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<Segment>, 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<u8>),
Event(Event),
}
pub enum Sniffed<'a> {
Plain(&'a [u8]),
Segments(Vec<Segment>),
}
#[derive(Default)]
struct Tokenizer {
state: TokenState,
buf: Vec<u8>,
}
#[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<u8> {
let separator = if payload.is_empty() { "" } else { ";" };
format!("\x1b]5522;{metadata}{separator}{payload}\x1b\\").into_bytes()
}
fn collect(chunks: &[&[u8]]) -> (Vec<u8>, Vec<Event>) {
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"<svg/>"),
),
&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"
);
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod agent_hooks;
pub mod cli_agent;
pub mod clipboard;
pub mod codename;
pub mod config;
pub mod crash;
+7
View File
@@ -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<String>,
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();
+1 -1
View File
@@ -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
+330 -35
View File
@@ -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<Sender<DaemonMsg>>,
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<bool>,
observers: Vec<Observer>,
observer_seq: u64,
cwd: Option<PathBuf>,
@@ -788,6 +801,17 @@ fn fan_out_output(st: &mut PaneState, bytes: &[u8], frames: Vec<GraphicsFrame>,
}
// 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<u8>),
Image(Vec<u8>),
Delete(Vec<u8>),
ClipboardWrite(Vec<u8>),
}
/// Queue an encoded image frame for the subscriber, dropping any frame larger
@@ -990,6 +1015,50 @@ fn push_image_frame(frames: &mut Vec<GraphicsFrame>, frame: Vec<u8>) {
}
}
fn process_graphics_output(
sniffed: Sniffed<'_>,
ordered: bool,
pass: &mut Vec<u8>,
frames: &mut Vec<GraphicsFrame>,
writer: &Arc<Mutex<Box<dyn Write + Send>>>,
) {
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<String>,
workspace: Option<String>,
restore: Option<Restore>,
allow_remote_clipboard_write: bool,
on_dead: impl FnOnce() + Send + 'static,
) -> anyhow::Result<Arc<Self>> {
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<NativeSshSpec>,
on_dead: impl FnOnce() + Send + 'static,
) -> anyhow::Result<Arc<Self>> {
let allow_remote_clipboard_write = spec.remote_clipboard_write;
let bridge = crate::daemon::ssh::session::make_bridge();
let reader_handle: Box<dyn Read + Send> = Box::new(bridge.reader);
let writer: Arc<Mutex<Box<dyn Write + Send>>> =
@@ -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<GraphicsFrame> = 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<DaemonMsg>) -> u64 {
self.attach_with_permissions(subscriber, false)
}
pub fn attach_with_permissions(
&self,
subscriber: Sender<DaemonMsg>,
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<DaemonMsg>) {
}
}
#[cfg(test)]
fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> 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<DaemonMsg>,
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<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
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<Mutex<Box<dyn Write + Send>>> =
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]
+55 -7
View File
@@ -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<String>,
#[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<String>,
workspace: Option<String>,
restore: Option<RestoreFrom>,
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<u8>),
/// 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<u8>),
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: Write>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> {
@@ -1016,6 +1025,8 @@ struct OwnedSpawn {
workspace: Option<String>,
#[serde(default)]
restore: Option<RestoreFrom>,
#[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"
+58 -20
View File
@@ -701,6 +701,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> 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<Registry>) -> 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<Registry>) -> 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<Registry>,
allow_remote_clipboard_write: bool,
) -> anyhow::Result<()> {
let (tx, rx) = mpsc::channel::<DaemonMsg>();
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<Registry>,
allow_remote_clipboard_write: bool,
) -> anyhow::Result<()> {
let (tx, rx) = mpsc::channel::<DaemonMsg>();
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();
@@ -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,
+3
View File
@@ -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();
@@ -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");
+6
View File
@@ -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 | |
+85
View File
@@ -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
+82 -2
View File
@@ -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<Mutex<VecDeque<tty7_core::core::clipboard::ClipboardWrite>>>,
clipboard_write_busy: Arc<AtomicBool>,
/// 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<Mutex<VecDeque<tty7_core::core::clipboard::ClipboardWrite>>>,
clipboard_write_busy: Arc<AtomicBool>,
/// 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<String> {
@@ -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<Mutex<Option<SshPhase>>> = 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<tty7_core::core::clipboard::ClipboardWrite> {
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<AgentSessionState> {
self.agent_session.lock().ok().and_then(|g| g.clone())
}
+246
View File
@@ -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<String>,
remote_clipboard_write_in_flight: Option<u64>,
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<Vec<u8>>
Some(out)
}
fn validate_remote_clipboard_image(
write: tty7_core::core::clipboard::ClipboardWrite,
) -> Result<(gpui::Image, Option<String>), 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<String> {
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<Self>,
) -> 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<Self>) {
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>) {
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);
+4
View File
@@ -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",
+4
View File
@@ -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 => "エージェント転送",
+2
View File
@@ -293,6 +293,8 @@ l10n_keys! {
SettingsGroupConnection,
SettingsGroupSession,
SettingsGroupSecurity,
SettingsRemoteClipboardWrite,
SettingsRemoteClipboardWriteDesc,
SettingsIdentityFiles,
SettingsIdentityFilesDesc,
SettingsAgentForwarding,
+4
View File
@@ -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 转发",
+21
View File
@@ -980,6 +980,7 @@ pub(crate) struct SshProfileForm {
x11: bool,
skip_banner: bool,
shell_integration: bool,
remote_clipboard_write: bool,
verify_host_keys: Option<bool>,
warn_on_close: Option<bool>,
@@ -1226,6 +1227,7 @@ pub(crate) struct SshFormDraft {
warn_on_close: Option<bool>,
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(
+4 -1
View File
@@ -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);