mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod agent_hooks;
|
||||
pub mod cli_agent;
|
||||
pub mod clipboard;
|
||||
pub mod codename;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "エージェント転送",
|
||||
|
||||
@@ -293,6 +293,8 @@ l10n_keys! {
|
||||
SettingsGroupConnection,
|
||||
SettingsGroupSession,
|
||||
SettingsGroupSecurity,
|
||||
SettingsRemoteClipboardWrite,
|
||||
SettingsRemoteClipboardWriteDesc,
|
||||
SettingsIdentityFiles,
|
||||
SettingsIdentityFilesDesc,
|
||||
SettingsAgentForwarding,
|
||||
|
||||
@@ -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 转发",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user