aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 02:51:59 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 02:51:59 +0300
commited478be9835e984c42bc1e30fe84b5fb01a8c5d4 (patch)
tree353b6ea5c7368a9f3b477bc8f1bbcd248e377577
parent2b97799b6715fd6c355db9c225029765971915e6 (diff)
fix: bridge remote clipboard image paste
refs #205
-rw-r--r--docs/next/CHANGELOG.md6
-rw-r--r--src/client/mod.rs58
-rw-r--r--src/platform/fallback.rs7
-rw-r--r--src/platform/linux.rs45
-rw-r--r--src/platform/macos.rs43
-rw-r--r--src/platform/mod.rs6
-rw-r--r--src/remote.rs184
-rw-r--r--src/server/client_transport.rs34
-rw-r--r--src/server/clipboard_image.rs128
-rw-r--r--src/server/headless.rs111
-rw-r--r--src/server/mod.rs1
-rw-r--r--src/server/protocol.rs39
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/cli_wrapper.rs6
-rw-r--r--tests/client_mode.rs40
-rw-r--r--tests/cross_area.rs20
-rw-r--r--tests/detach_reattach.rs44
-rw-r--r--tests/multi_client.rs6
-rw-r--r--tests/server_headless.rs16
-rw-r--r--tests/support/mod.rs2
20 files changed, 693 insertions, 105 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 8a487d90..43af669f 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -6,6 +6,12 @@
- Added an integrations tab in settings and first-run onboarding so users can install recommended agent integrations from inside Herdr.
- Added `terminal.default_shell` to choose the executable used for new interactive panes. When unset, Herdr still falls back to `$SHELL`, then `/bin/sh`. (#196)
+### Fixed
+- Remote clients now bridge local clipboard images into the remote pane by staging them as temporary image files and pasting the remote path, so Claude Code image paste works over `herdr --remote`. (#205)
+
+### Breaking Changes
+- The client/server protocol is now version 8. Stop and restart any running v0.5.12 server before attaching with this release.
+
## [0.5.12] - 2026-05-19
### Fixed
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 101e168c..ff5c144f 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -32,8 +32,8 @@ use tracing::{debug, info, warn};
use crate::server::headless::client_socket_path;
use crate::server::protocol::{
- self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE,
- MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
@@ -640,6 +640,34 @@ async fn run_client_loop(
}
data
};
+ if should_bridge_clipboard_image_paste(&data) {
+ if let Some(image) = crate::platform::read_clipboard_image() {
+ if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
+ warn!(
+ bytes = image.bytes.len(),
+ max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ "local clipboard image is too large to bridge"
+ );
+ continue;
+ }
+ info!(
+ bytes = image.bytes.len(),
+ extension = image.extension,
+ "bridging local clipboard image paste to remote server"
+ );
+ let msg = ClientMessage::ClipboardImage {
+ extension: image.extension.to_owned(),
+ data: image.bytes,
+ };
+ if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ return Err(ClientError::ConnectionLost(e));
+ }
+ continue;
+ }
+ info!(
+ "clipboard image paste trigger received, but local clipboard has no image"
+ );
+ }
let msg = ClientMessage::Input { data };
if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
@@ -875,6 +903,21 @@ fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
}
}
+fn should_bridge_clipboard_image_paste(data: &[u8]) -> bool {
+ if data == b"\x1b[200~\x1b[201~" {
+ return true;
+ }
+
+ let events = crate::raw_input::parse_raw_input_bytes_sync(data);
+ matches!(
+ events.as_slice(),
+ [crate::raw_input::RawInputEvent::Key(key)]
+ if key.kind == crossterm::event::KeyEventKind::Press
+ && key.modifiers == crossterm::event::KeyModifiers::CONTROL
+ && matches!(key.code, crossterm::event::KeyCode::Char('v' | 'V'))
+ )
+}
+
// ---------------------------------------------------------------------------
// Clipboard forwarding
// ---------------------------------------------------------------------------
@@ -1069,6 +1112,17 @@ mod tests {
use super::*;
#[test]
+ fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() {
+ assert!(should_bridge_clipboard_image_paste(&[0x16]));
+ assert!(should_bridge_clipboard_image_paste(b"\x1b[118;5u"));
+ assert!(should_bridge_clipboard_image_paste(b"\x1b[200~\x1b[201~"));
+ assert!(!should_bridge_clipboard_image_paste(
+ b"\x1b[200~text\x1b[201~"
+ ));
+ assert!(!should_bridge_clipboard_image_paste(b"v"));
+ }
+
+ #[test]
fn graphics_bytes_are_written_after_blit_with_saved_cursor() {
let mut output = Vec::new();
write_encoded_frame_with_graphics(
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs
index bf3ac42b..825aaccd 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -1,6 +1,6 @@
use std::path::PathBuf;
-use super::{ForegroundJob, Signal};
+use super::{ClipboardImage, ForegroundJob, Signal};
/// Unsupported platform stub.
pub fn foreground_job(_child_pid: u32) -> Option<ForegroundJob> {
@@ -36,6 +36,11 @@ pub fn write_clipboard(_bytes: &[u8]) -> bool {
}
/// Unsupported platform stub.
+pub fn read_clipboard_image() -> Option<ClipboardImage> {
+ None
+}
+
+/// Unsupported platform stub.
pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result<bool> {
Ok(false)
}
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 6823cca8..4d2ed049 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -4,7 +4,7 @@ use std::{
process::{Command, Stdio},
};
-use super::{ClipboardCommand, ForegroundJob, ForegroundProcess, Signal};
+use super::{ClipboardCommand, ClipboardImage, ForegroundJob, ForegroundProcess, Signal};
/// Collect the foreground terminal job for a given child PID.
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
@@ -155,6 +155,34 @@ pub fn write_clipboard(bytes: &[u8]) -> bool {
false
}
+pub fn read_clipboard_image() -> Option<ClipboardImage> {
+ for (mime, extension) in [
+ ("image/png", "png"),
+ ("image/jpeg", "jpg"),
+ ("image/jpg", "jpg"),
+ ("image/gif", "gif"),
+ ("image/webp", "webp"),
+ ("image/bmp", "bmp"),
+ ] {
+ if std::env::var_os("WAYLAND_DISPLAY").is_some() {
+ if let Some(bytes) = read_clipboard_image_with_command("wl-paste", &["--type", mime]) {
+ return Some(ClipboardImage { bytes, extension });
+ }
+ }
+
+ if std::env::var_os("DISPLAY").is_some() {
+ if let Some(bytes) = read_clipboard_image_with_command(
+ "xclip",
+ &["-selection", "clipboard", "-t", mime, "-o"],
+ ) {
+ return Some(ClipboardImage { bytes, extension });
+ }
+ }
+ }
+
+ None
+}
+
/// Show a native desktop notification through libnotify's command-line helper.
pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> {
show_desktop_notification_with_command(title, body, |program| Command::new(program))
@@ -192,6 +220,21 @@ fn run_notification_command(mut command: Command) -> std::io::Result<bool> {
Ok(status.success())
}
+fn read_clipboard_image_with_command(program: &str, args: &[&str]) -> Option<Vec<u8>> {
+ let output = Command::new(program)
+ .args(args)
+ .stdin(Stdio::null())
+ .stderr(Stdio::null())
+ .output()
+ .ok()?;
+
+ if output.status.success() && !output.stdout.is_empty() {
+ Some(output.stdout)
+ } else {
+ None
+ }
+}
+
fn clipboard_commands() -> Vec<ClipboardCommand> {
let mut commands = Vec::new();
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index f085ee91..d0cf43c8 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
-use super::{ClipboardCommand, ForegroundJob, ForegroundProcess, Signal};
+use super::{ClipboardCommand, ClipboardImage, ForegroundJob, ForegroundProcess, Signal};
const PROC_PGRP_ONLY: u32 = 2;
@@ -202,6 +202,47 @@ pub fn write_clipboard(bytes: &[u8]) -> bool {
)
}
+pub fn read_clipboard_image() -> Option<ClipboardImage> {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-clipboard-image-{}-{}.png",
+ std::process::id(),
+ unique_timestamp_nanos()
+ ));
+ let script = format!(
+ "set png_data to (the clipboard as «class PNGf»)\nset fp to open for access POSIX file \"{}\" with write permission\nwrite png_data to fp\nclose access fp",
+ path.display()
+ );
+
+ let status = Command::new("osascript")
+ .arg("-e")
+ .arg(script)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .ok()?;
+
+ if !status.success() {
+ let _ = std::fs::remove_file(&path);
+ return None;
+ }
+
+ let bytes = std::fs::read(&path).ok();
+ let _ = std::fs::remove_file(&path);
+ let bytes = bytes?;
+ (!bytes.is_empty()).then_some(ClipboardImage {
+ bytes,
+ extension: "png",
+ })
+}
+
+fn unique_timestamp_nanos() -> u128 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0)
+}
+
/// Show a native macOS notification.
///
/// Prefer `terminal-notifier` when it is installed because it can activate the
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 58675d33..00fa8833 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -30,6 +30,12 @@ pub struct ClipboardCommand {
pub args: &'static [&'static str],
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ClipboardImage {
+ pub bytes: Vec<u8>,
+ pub extension: &'static str,
+}
+
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
diff --git a/src/remote.rs b/src/remote.rs
index 22f6ba74..5ab7e6fd 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -18,6 +18,7 @@ use std::time::Duration;
const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
+const CURRENT_PROTOCOL: u32 = crate::server::protocol::PROTOCOL_VERSION;
const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
@@ -89,6 +90,7 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
.unwrap_or_else(|| "herdr".to_string());
let reattach_command = reattach_command(&program, &remote.target, &session_name);
let remote_herdr = prepare_remote_herdr(&remote.target)?;
+ ensure_remote_server_compatible(&remote.target, &remote_herdr)?;
let _bridge = SshStdioBridge::start(
remote.target,
@@ -130,7 +132,18 @@ pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
fn ensure_remote_server_running() -> io::Result<()> {
let socket_path = crate::server::headless::client_socket_path();
if crate::server::autodetect::is_server_listening() {
- return Ok(());
+ let status = crate::api::read_runtime_status_at(
+ &crate::api::socket_path(),
+ Duration::from_millis(500),
+ )?
+ .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?;
+ if status.protocol == Some(CURRENT_PROTOCOL) {
+ return Ok(());
+ }
+ return Err(io::Error::other(format!(
+ "remote herdr server is running with protocol {}, but this bridge needs protocol {CURRENT_PROTOCOL}; rerun `herdr --remote` from an interactive terminal to approve replacing it",
+ protocol_label(status.protocol)
+ )));
}
crate::server::autodetect::spawn_server_daemon()?;
@@ -311,7 +324,8 @@ fn remote_path_probe_command() -> &'static str {
r#"path=$(command -v herdr) || exit 1
test -n "$path" || exit 1
version=$("$path" --version) || exit 1
-printf '%s\n%s\n' "$path" "$version"
+status=$("$path" status client) || exit 1
+printf '%s\n%s\n%s\n' "$path" "$version" "$status"
"#
}
@@ -319,7 +333,12 @@ fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Opt
let mut lines = stdout.lines();
let path = lines.next()?;
let version = lines.next()?.trim();
- if !path.starts_with('/') || version != format!("herdr {CURRENT_VERSION}") {
+ let status = lines.collect::<Vec<_>>().join("\n");
+ let protocol = parse_status_protocol(&status)?;
+ if !path.starts_with('/')
+ || version != format!("herdr {CURRENT_VERSION}")
+ || protocol != CURRENT_PROTOCOL
+ {
return None;
}
@@ -327,14 +346,21 @@ fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Opt
}
fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
- let command = format!("test -x {0} && {0} --version", remote_herdr.shell_path);
+ let command = format!(
+ "test -x {0} && {0} --version && {0} status client",
+ remote_herdr.shell_path
+ );
let output = ssh_output(target, &command)?;
if !output.status.success() {
return Ok(false);
}
- let version = String::from_utf8_lossy(&output.stdout);
- Ok(version.trim() == format!("herdr {CURRENT_VERSION}"))
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut lines = stdout.lines();
+ let version = lines.next().unwrap_or_default().trim();
+ let status = lines.collect::<Vec<_>>().join("\n");
+ Ok(version == format!("herdr {CURRENT_VERSION}")
+ && parse_status_protocol(&status) == Some(CURRENT_PROTOCOL))
}
fn remote_binary_override_path() -> io::Result<Option<PathBuf>> {
@@ -402,6 +428,107 @@ fn resolve_install_source(
download_release_asset(platform)
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RemoteServerStatus {
+ Running { protocol: Option<u32> },
+ NotRunning,
+}
+
+fn ensure_remote_server_compatible(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+ let status = remote_server_status(target, remote_herdr)?;
+ let RemoteServerStatus::Running { protocol } = status else {
+ return Ok(());
+ };
+
+ if protocol == Some(CURRENT_PROTOCOL) {
+ return Ok(());
+ }
+
+ confirm_remote_server_stop(target, protocol)?;
+ stop_remote_server(target, remote_herdr)
+}
+
+fn remote_server_status(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+) -> io::Result<RemoteServerStatus> {
+ let command = format!("{} status server", remote_herdr.shell_path);
+ let output = ssh_output(target, &command)?;
+ if !output.status.success() {
+ return Err(command_failed("remote server status failed", &output));
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ if stdout
+ .lines()
+ .any(|line| line.trim() == "status: not running")
+ {
+ return Ok(RemoteServerStatus::NotRunning);
+ }
+
+ if stdout.lines().any(|line| line.trim() == "status: running") {
+ return Ok(RemoteServerStatus::Running {
+ protocol: parse_status_protocol(&stdout),
+ });
+ }
+
+ Err(io::Error::other(format!(
+ "could not parse remote server status from `{}`",
+ stdout.trim()
+ )))
+}
+
+fn parse_status_protocol(status: &str) -> Option<u32> {
+ status.lines().find_map(|line| {
+ let (_, value) = line.trim().split_once(':')?;
+ (line.trim_start().starts_with("protocol:")).then(|| value.trim().parse().ok())?
+ })
+}
+
+fn confirm_remote_server_stop(target: &str, protocol: Option<u32>) -> io::Result<()> {
+ if !io::stdin().is_terminal() {
+ return Err(io::Error::other(format!(
+ "remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}; run from an interactive terminal to approve stopping it",
+ protocol_label(protocol)
+ )));
+ }
+
+ eprintln!(
+ "remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}.",
+ protocol_label(protocol)
+ );
+ eprint!("Install/replace the remote binary if needed and stop the running remote server now? This will kill the remote Herdr server. [Y/n] ");
+ io::stderr().flush()?;
+
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ let answer = answer.trim().to_ascii_lowercase();
+ if answer == "n" || answer == "no" {
+ return Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote herdr server replacement cancelled",
+ ));
+ }
+
+ Ok(())
+}
+
+fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+ let command = format!("{} server stop", remote_herdr.shell_path);
+ let output = ssh_output(target, &command)?;
+ if output.status.success() {
+ Ok(())
+ } else {
+ Err(command_failed("remote server stop failed", &output))
+ }
+}
+
+fn protocol_label(protocol: Option<u32>) -> String {
+ protocol
+ .map(|value| value.to_string())
+ .unwrap_or_else(|| "unknown".to_string())
+}
+
fn warn_if_remote_bin_not_on_path(target: &str) -> io::Result<()> {
let output = ssh_output(
target,
@@ -986,7 +1113,7 @@ mod tests {
os: "linux",
arch: "x86_64",
});
- let stdout = format!("/usr/bin/herdr\nherdr {CURRENT_VERSION}\n");
+ let stdout = matching_path_probe_stdout("/usr/bin/herdr");
let remote_herdr =
remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary");
@@ -1002,7 +1129,7 @@ mod tests {
os: "linux",
arch: "x86_64",
});
- let stdout = format!("/opt/herdr bin/herdr\nherdr {CURRENT_VERSION}\n");
+ let stdout = matching_path_probe_stdout("/opt/herdr bin/herdr");
let remote_herdr =
remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary");
@@ -1018,7 +1145,7 @@ mod tests {
os: "macos",
arch: "aarch64",
});
- let stdout = format!("/opt/homebrew/bin/herdr\nherdr {CURRENT_VERSION}\n");
+ let stdout = matching_path_probe_stdout("/opt/homebrew/bin/herdr");
let remote_herdr =
remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary");
@@ -1035,7 +1162,7 @@ mod tests {
os: "linux",
arch: "x86_64",
});
- let stdout = format!("/opt/herdr's/bin/herdr\nherdr {CURRENT_VERSION}\n");
+ let stdout = matching_path_probe_stdout("/opt/herdr's/bin/herdr");
let remote_herdr =
remote_herdr_from_path_probe(&remote_herdr, &stdout).expect("matching path binary");
@@ -1051,8 +1178,10 @@ mod tests {
os: "linux",
arch: "x86_64",
});
- let remote_herdr =
- remote_herdr_from_path_probe(&remote_herdr, "/usr/bin/herdr\nherdr 0.0.0\n");
+ let remote_herdr = remote_herdr_from_path_probe(
+ &remote_herdr,
+ &format!("/usr/bin/herdr\nherdr 0.0.0\nprotocol: {CURRENT_PROTOCOL}\n"),
+ );
assert!(remote_herdr.is_none());
}
@@ -1063,13 +1192,38 @@ mod tests {
os: "linux",
arch: "x86_64",
});
- let stdout = format!("bin/herdr\nherdr {CURRENT_VERSION}\n");
+ let stdout = matching_path_probe_stdout("bin/herdr");
let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout);
assert!(remote_herdr.is_none());
}
#[test]
+ fn remote_path_probe_ignores_protocol_mismatch() {
+ let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
+ os: "linux",
+ arch: "x86_64",
+ });
+ let stdout = format!("/usr/bin/herdr\nherdr {CURRENT_VERSION}\nprotocol: 0\n");
+ let remote_herdr = remote_herdr_from_path_probe(&remote_herdr, &stdout);
+
+ assert!(remote_herdr.is_none());
+ }
+
+ #[test]
+ fn parse_status_protocol_reads_protocol_line() {
+ assert_eq!(
+ parse_status_protocol("version: x\nprotocol: 8\nbinary: y"),
+ Some(8)
+ );
+ assert_eq!(
+ parse_status_protocol("status: running\nprotocol: unknown"),
+ None
+ );
+ assert_eq!(parse_status_protocol("status: not running"), None);
+ }
+
+ #[test]
fn install_source_description_uses_override_binary() {
let platform = RemotePlatform {
os: "linux",
@@ -1093,6 +1247,10 @@ mod tests {
assert!(source.temporary_dir.is_none());
}
+ fn matching_path_probe_stdout(path: &str) -> String {
+ format!("{path}\nherdr {CURRENT_VERSION}\nprotocol: {CURRENT_PROTOCOL}\n")
+ }
+
fn remote_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index 971c879f..fc6ab0be 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -14,7 +14,8 @@ use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::server::protocol::{
- self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
+ MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
/// Minimum accepted attached client size.
@@ -58,6 +59,12 @@ pub(crate) enum ServerEvent {
},
/// A client sent an input message.
ClientInput { client_id: u64, data: Vec<u8> },
+ /// A client sent local clipboard image bytes to paste into a remote pane.
+ ClientClipboardImage {
+ client_id: u64,
+ extension: String,
+ data: Vec<u8>,
+ },
/// A client requested direct attach to one terminal.
ClientAttachTerminal {
client_id: u64,
@@ -305,7 +312,8 @@ fn client_read_loop(
should_quit: &Arc<AtomicBool>,
) -> io::Result<()> {
while !should_quit.load(Ordering::Acquire) {
- let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
+ let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE)
+ {
Ok(msg) => msg,
Err(protocol::FramingError::UnexpectedEof) => {
// Client disconnected.
@@ -339,11 +347,31 @@ fn client_read_loop(
size = data.len(),
"oversized input from client, closing"
);
- ServerEvent::ClientDisconnected { client_id }
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
} else {
ServerEvent::ClientInput { client_id, data }
}
}
+ ClientMessage::ClipboardImage { extension, data } => {
+ if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
+ warn!(
+ client_id,
+ size = data.len(),
+ "oversized clipboard image from client, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ } else {
+ ServerEvent::ClientClipboardImage {
+ client_id,
+ extension,
+ data,
+ }
+ }
+ }
ClientMessage::Resize {
cols,
rows,
diff --git a/src/server/clipboard_image.rs b/src/server/clipboard_image.rs
new file mode 100644
index 00000000..299267d3
--- /dev/null
+++ b/src/server/clipboard_image.rs
@@ -0,0 +1,128 @@
+use std::fs;
+use std::io::{self, Write as _};
+use std::path::{Path, PathBuf};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+const STAGED_CLIPBOARD_IMAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
+
+pub(crate) struct StagedClipboardImage {
+ pub(crate) path: PathBuf,
+ pub(crate) paste_text: String,
+}
+
+pub(crate) fn stage(
+ client_id: u64,
+ extension: &str,
+ data: &[u8],
+) -> io::Result<StagedClipboardImage> {
+ use std::os::unix::fs::OpenOptionsExt;
+
+ let extension = sanitize_extension(extension);
+ let dir = ensure_staging_dir()?;
+ cleanup_stale(&dir);
+
+ let unique = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0);
+
+ for attempt in 0..100 {
+ let path = dir.join(format!(
+ "client-{client_id}-clipboard-{unique}-{attempt}.{extension}"
+ ));
+ let mut file = match fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(&path)
+ {
+ Ok(file) => file,
+ Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(err) => return Err(err),
+ };
+ file.write_all(data)?;
+ return Ok(StagedClipboardImage {
+ paste_text: path.to_string_lossy().into_owned(),
+ path,
+ });
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "failed to allocate unique clipboard image staging path",
+ ))
+}
+
+pub(crate) fn remove_files(paths: Vec<PathBuf>) {
+ for path in paths {
+ let _ = fs::remove_file(path);
+ }
+}
+
+fn sanitize_extension(extension: &str) -> &'static str {
+ if extension.eq_ignore_ascii_case("png") {
+ "png"
+ } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") {
+ "jpg"
+ } else if extension.eq_ignore_ascii_case("gif") {
+ "gif"
+ } else if extension.eq_ignore_ascii_case("webp") {
+ "webp"
+ } else if extension.eq_ignore_ascii_case("bmp") {
+ "bmp"
+ } else {
+ "png"
+ }
+}
+
+fn staging_dir() -> PathBuf {
+ let user_id = unsafe { libc::geteuid() };
+ std::env::temp_dir().join(format!("herdr-clipboard-images-{user_id}"))
+}
+
+fn ensure_staging_dir() -> io::Result<PathBuf> {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = staging_dir();
+ fs::create_dir_all(&dir)?;
+ let metadata = fs::metadata(&dir)?;
+ if !metadata.is_dir() {
+ return Err(io::Error::other(format!(
+ "clipboard image staging path is not a directory: {}",
+ dir.display()
+ )));
+ }
+ fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
+ Ok(dir)
+}
+
+fn cleanup_stale(dir: &Path) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let Ok(metadata) = entry.metadata() else {
+ continue;
+ };
+ let Ok(modified) = metadata.modified() else {
+ continue;
+ };
+ if modified.elapsed().unwrap_or_default() > STAGED_CLIPBOARD_IMAGE_MAX_AGE {
+ let _ = fs::remove_file(path);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sanitize_extension_accepts_known_image_extensions() {
+ assert_eq!(sanitize_extension("PNG"), "png");
+ assert_eq!(sanitize_extension("jpeg"), "jpg");
+ assert_eq!(sanitize_extension("webp"), "webp");
+ assert_eq!(sanitize_extension("sh"), "png");
+ }
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 56164cfa..ad7ea03a 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -64,6 +64,18 @@ enum LoopEvent {
const MIN_COLS: u16 = 80;
const MIN_ROWS: u16 = 24;
+fn paste_payload_for_runtime(runtime: &crate::terminal::TerminalRuntime, text: &str) -> String {
+ if runtime
+ .input_state()
+ .map(|state| state.bracketed_paste)
+ .unwrap_or(false)
+ {
+ format!("\x1b[200~{text}\x1b[201~")
+ } else {
+ text.to_owned()
+ }
+}
+
/// Legacy environment variable for overriding the client socket path.
///
/// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`.
@@ -237,6 +249,8 @@ struct ClientConnection {
render_pending: bool,
/// Last host mouse capture mode sent to this client.
host_mouse_capture_active: Option<bool>,
+ /// Temporary files staged from this client's local clipboard image pastes.
+ staged_clipboard_files: Vec<PathBuf>,
/// Channels for sending framed ServerMessage data to the client writer thread.
writer: Option<ClientWriter>,
}
@@ -285,6 +299,7 @@ impl ClientConnection {
graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(),
render_pending: false,
host_mouse_capture_active: None,
+ staged_clipboard_files: Vec::new(),
writer,
}
}
@@ -653,17 +668,16 @@ impl HeadlessServer {
let was_foreground = self.foreground_client_id == Some(client_id);
self.send_client_graphics_cleanup(client_id);
let removed = self.clients.remove(&client_id);
- if let Some(ClientConnection {
- mode: ClientConnectionMode::TerminalAttach { terminal_id },
- ..
- }) = removed
- {
- self.terminal_attach_owners.remove(&terminal_id);
- if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) {
- self.app
- .state
- .direct_attach_resize_locks
- .remove(&terminal_id);
+ if let Some(removed) = removed {
+ crate::server::clipboard_image::remove_files(removed.staged_clipboard_files);
+ if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode {
+ self.terminal_attach_owners.remove(&terminal_id);
+ if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) {
+ self.app
+ .state
+ .direct_attach_resize_locks
+ .remove(&terminal_id);
+ }
}
}
if was_foreground {
@@ -834,6 +848,49 @@ impl HeadlessServer {
self.app.state.terminal_runtimes.get(&terminal_id)
}
+ fn write_client_clipboard_image(
+ &mut self,
+ client_id: u64,
+ extension: &str,
+ data: &[u8],
+ ) -> std::io::Result<String> {
+ let staged = crate::server::clipboard_image::stage(client_id, extension, data)?;
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.staged_clipboard_files.push(staged.path);
+ }
+ info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image");
+ Ok(staged.paste_text)
+ }
+
+ fn paste_client_clipboard_image_path(&mut self, client_id: u64, path: String) -> bool {
+ if let Some(ClientConnection {
+ mode: ClientConnectionMode::TerminalAttach { terminal_id },
+ ..
+ }) = self.clients.get(&client_id)
+ {
+ if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) {
+ let payload = paste_payload_for_runtime(runtime, &path);
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) {
+ warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed");
+ }
+ }
+ return true;
+ }
+
+ let foreground_changed = self.promote_client_to_foreground(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.request_semantic_redraw_after_input();
+ }
+ self.app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Paste(path)],
+ self.foreground_client_id == Some(client_id),
+ );
+ true
+ }
+
fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState {
self.app
.state
@@ -1451,6 +1508,25 @@ impl HeadlessServer {
foreground_changed || theme_changed || interaction
}
}
+ ServerEvent::ClientClipboardImage {
+ client_id,
+ extension,
+ data,
+ } => {
+ debug!(
+ client_id,
+ len = data.len(),
+ extension = %extension,
+ "client clipboard image received"
+ );
+ match self.write_client_clipboard_image(client_id, &extension, &data) {
+ Ok(path) => self.paste_client_clipboard_image_path(client_id, path),
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to stage client clipboard image");
+ true
+ }
+ }
+ }
ServerEvent::ClientResize {
client_id,
cols,
@@ -2101,7 +2177,12 @@ impl HeadlessServer {
self.drain_api_requests_with_shutdown_check();
// Close all client connections.
- self.clients.clear();
+ let staged_files = self
+ .clients
+ .drain()
+ .flat_map(|(_, client)| client.staged_clipboard_files)
+ .collect::<Vec<_>>();
+ crate::server::clipboard_image::remove_files(staged_files);
// Remove socket files.
self.cleanup_sockets()?;
@@ -2126,6 +2207,12 @@ impl HeadlessServer {
impl Drop for HeadlessServer {
fn drop(&mut self) {
+ let staged_files = self
+ .clients
+ .drain()
+ .flat_map(|(_, client)| client.staged_clipboard_files)
+ .collect::<Vec<_>>();
+ crate::server::clipboard_image::remove_files(staged_files);
let _ = self.cleanup_sockets();
}
}
diff --git a/src/server/mod.rs b/src/server/mod.rs
index eab079ee..b41e2a67 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -1,5 +1,6 @@
pub mod autodetect;
pub(crate) mod client_transport;
+pub(crate) mod clipboard_image;
pub mod headless;
pub mod protocol;
pub(crate) mod render_stream;
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index abc3abec..6710bc80 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 7;
+pub const PROTOCOL_VERSION: u32 = 8;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -24,6 +24,9 @@ pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024;
/// image payloads that are naturally much larger after base64 encoding.
pub const MAX_GRAPHICS_FRAME_SIZE: usize = 32 * 1024 * 1024;
+/// Maximum clipboard image payload size for remote paste bridging.
+pub const MAX_CLIPBOARD_IMAGE_PAYLOAD: usize = 16 * 1024 * 1024;
+
/// Length of the u32 little-endian length prefix in bytes.
const LENGTH_PREFIX_BYTES: usize = 4;
@@ -65,6 +68,14 @@ pub enum ClientMessage {
data: Vec<u8>,
},
+ /// Image bytes read from the client's local clipboard for remote paste bridging.
+ ClipboardImage {
+ /// Image file extension without a leading dot.
+ extension: String,
+ /// Raw image bytes.
+ data: Vec<u8>,
+ },
+
/// Terminal resize notification from the client.
Resize {
/// New terminal width in columns.
@@ -616,6 +627,18 @@ mod tests {
}
#[test]
+ fn client_clipboard_image_roundtrip() {
+ let msg = ClientMessage::ClipboardImage {
+ extension: "png".to_owned(),
+ data: vec![0x89, b'P', b'N', b'G'],
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
fn client_input_large_multilingual_payload_roundtrip() {
let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024);
assert!(text.len() > 64 * 1024);
@@ -933,7 +956,7 @@ mod tests {
let mut expected = Vec::new();
for i in 0..150u32 {
- let msg = match i % 4 {
+ let msg = match i % 5 {
0 => ClientMessage::Hello {
version: PROTOCOL_VERSION,
cols: (80 + (i % 40) as u16),
@@ -945,13 +968,17 @@ mod tests {
1 => ClientMessage::Input {
data: vec![(i % 256) as u8; (i as usize % 50) + 1],
},
- 2 => ClientMessage::Resize {
+ 2 => ClientMessage::ClipboardImage {
+ extension: "png".to_owned(),
+ data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8],
+ },
+ 3 => ClientMessage::Resize {
cols: (100 + (i % 30) as u16),
rows: (30 + (i % 10) as u16),
cell_width_px: 8,
cell_height_px: 16,
},
- 3 => ClientMessage::Detach,
+ 4 => ClientMessage::Detach,
_ => unreachable!(),
};
write_message(&mut buf, &msg).unwrap();
@@ -1408,6 +1435,10 @@ mod tests {
ClientMessage::Input {
data: b"hello world".to_vec(),
},
+ ClientMessage::ClipboardImage {
+ extension: "png".to_owned(),
+ data: vec![0x89, b'P', b'N', b'G'],
+ },
ClientMessage::Resize {
cols: 100,
rows: 30,
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index d01b1efa..1289abe6 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -227,7 +227,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
// Intentionally hardcoded so wire protocol bumps require updating this test.
// Changing this value means old clients/servers are no longer compatible.
- assert_eq!(value["result"]["protocol"], 7);
+ assert_eq!(value["result"]["protocol"], 8);
cleanup_spawned_herdr(child, base);
}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index aa06302e..de1f1457 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -974,7 +974,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" protocol: 7"),
+ full_stdout.contains(" protocol: 8"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@@ -1007,7 +1007,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 7"),
+ server_stdout.contains("protocol: 8"),
"stdout: {server_stdout}"
);
@@ -1019,7 +1019,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
- client_stdout.contains("protocol: 7"),
+ client_stdout.contains("protocol: 8"),
"stdout: {client_stdout}"
);
assert!(
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index 83bc3638..4fe5b11a 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -267,8 +267,8 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7, "server should report protocol version 7");
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8, "server should report protocol version 8");
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@@ -335,8 +335,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
stream
@@ -384,8 +384,8 @@ fn client_input_forwarded_to_pane() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Send an Input message containing "echo hello\n".
@@ -438,8 +438,8 @@ fn client_resize_sends_message() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain the initial frame(s).
@@ -448,9 +448,9 @@ fn client_resize_sends_message() {
.unwrap();
while read_server_message(&mut stream).is_ok() {}
- // Send a Resize message: ClientMessage::Resize is variant 2.
+ // Send a Resize message: ClientMessage::Resize is variant 3.
let resize_payload = {
- let mut buf = encode_varint_u32(2); // variant 2 = Resize
+ let mut buf = encode_varint_u32(3); // variant 3 = Resize
buf.extend_from_slice(&encode_varint_u16(120)); // cols
buf.extend_from_slice(&encode_varint_u16(40)); // rows
buf.extend_from_slice(&encode_varint_u32(8)); // cell_width_px
@@ -497,8 +497,8 @@ fn server_shutdown_sends_message_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Send SIGINT so the server takes the graceful shutdown path and
@@ -725,8 +725,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
read_next_frame_payload(&mut stream, Duration::from_secs(10))
@@ -772,8 +772,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -890,8 +890,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -988,8 +988,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index d57a19c1..87e85109 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -476,8 +476,8 @@ fn send_client_input(stream: &mut UnixStream, data: &[u8]) {
}
fn send_client_detach(stream: &mut UnixStream) {
- // ClientMessage::Detach = variant 3
- let payload = encode_varint_u32(3);
+ // ClientMessage::Detach = variant 4
+ let payload = encode_varint_u32(4);
stream
.write_all(&frame_message(&payload))
.expect("write detach");
@@ -678,7 +678,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 7, 100, 30);
+ client_handshake(&mut client_a, 8, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -715,7 +715,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 7, 80, 24);
+ client_handshake(&mut client_b, 8, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -771,7 +771,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 7, 100, 30);
+ client_handshake(&mut client_a, 8, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -824,7 +824,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 7, 80, 24);
+ client_handshake(&mut client_b, 8, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@@ -869,7 +869,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
- client_handshake(&mut client, 7, 100, 30);
+ client_handshake(&mut client, 8, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -932,9 +932,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 7, 110, 30);
+ client_handshake(&mut client_a, 8, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 7, 100, 30);
+ client_handshake(&mut client_b, 8, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -1100,7 +1100,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
- client_handshake(&mut reconnect_client, 7, 80, 24);
+ client_handshake(&mut reconnect_client, 8, 80, 24);
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index a66d35c9..9b1e3681 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream_a, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream_b, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 7, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream_b, 8, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 7, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream_b, 8, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
let (version, error) =
- client_handshake(&mut stream, 7, 120, 40).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream, 8, 120, 40).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream_a, 8, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 7, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 7);
+ client_handshake(&mut stream_b, 8, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 8);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index c808f057..ba45bde6 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -549,7 +549,7 @@ fn client_handshake(
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
- client_handshake(&mut stream, 7, cols, rows).expect("handshake should succeed");
+ client_handshake(&mut stream, 8, cols, rows).expect("handshake should succeed");
stream
}
@@ -566,8 +566,8 @@ fn send_client_input(stream: &mut UnixStream, data: &[u8]) {
}
fn send_client_detach(stream: &mut UnixStream) {
- // ClientMessage::Detach = variant 3
- let payload = encode_varint_u32(3);
+ // ClientMessage::Detach = variant 4
+ let payload = encode_varint_u32(4);
stream.write_all(&frame_message(&payload)).unwrap();
stream.flush().unwrap();
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 9ea47024..99f72404 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -583,11 +583,11 @@ fn client_handshake_succeeds() {
// Connect to the client socket and perform a handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- // Send Hello with version 7, 80 cols, 24 rows.
+ // Send Hello with version 8, 80 cols, 24 rows.
let (version, error) =
- client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 7, "server should report protocol version 7");
+ assert_eq!(version, 8, "server should report protocol version 8");
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@@ -616,7 +616,7 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
- assert_eq!(version, 7, "server should report its version 7");
+ assert_eq!(version, 8, "server should report its version 8");
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@@ -641,10 +641,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) = client_handshake(&mut stream, 7, 0, 0)
+ let (version, error) = client_handshake(&mut stream, 8, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
- assert_eq!(version, 7);
+ assert_eq!(version, 8);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@@ -704,9 +704,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
- let (version, error) = client_handshake(&mut good_stream, 7, 80, 24)
+ let (version, error) = client_handshake(&mut good_stream, 8, 80, 24)
.expect("proper handshake should still work after no-hello client");
- assert_eq!(version, 7);
+ assert_eq!(version, 8);
assert!(error.is_none());
// API should still work.
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 62b63c9e..e81afd57 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -276,7 +276,7 @@ pub fn send_input(stream: &mut UnixStream, data: &[u8]) -> Result<(), String> {
}
pub fn send_detach(stream: &mut UnixStream) -> Result<(), String> {
- let detach_payload = encode_varint_u32(3);
+ let detach_payload = encode_varint_u32(4);
let framed = frame_message(&detach_payload);
stream
.write_all(&framed)