Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/next/api/herdr-api.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"protocol": 18,
"protocol": 19,
"schema_version": 1,
"schemas": {
"error_response": {
Expand Down
4 changes: 4 additions & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,10 @@ fn do_handshake(
} else {
ClientLaunchMode::App
},
// The server is persistent and its own environment belongs to whichever terminal
// started it, so only the client can report this. The remote SSH bridge forwards
// no environment, making Hello the only channel that reaches a remote server.
term_program: crate::terminal_notify::outer_term_program(),
};
protocol::write_message(stream, &hello)
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,10 @@ fn main() -> io::Result<()> {

init_logging();

// No client attaches in this mode, so nothing else reports the outer terminal. This
// process owns the terminal directly, so its own environment is the right answer.
pane::set_outer_term_program(terminal_notify::outer_term_program());

let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let event_hub = api::EventHub::default();
let _api_server = match api::start_server_with_capabilities(api_tx, event_hub.clone(), None) {
Expand Down
151 changes: 150 additions & 1 deletion src/pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::io;
use std::path::Path;
use std::sync::{
atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU64, Ordering},
Arc, Mutex,
Arc, Mutex, RwLock,
};

use bytes::Bytes;
Expand Down Expand Up @@ -54,13 +54,106 @@ const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::
const PANE_TERM: &str = "xterm-256color";
const PANE_COLORTERM: &str = "truecolor";

/// Terminal emulator identity reported by the foreground client, or `None` when no
/// client is attached or it could not be determined.
///
/// Panes inherit a snapshot of this process's environment, and the server is persistent:
/// its own terminal variables belong to whichever terminal started it, which may be a
/// different terminal from the attached client's, or none at all under a remote SSH
/// bridge. The attached client is the only authority on what actually renders pane output.
static OUTER_TERM_PROGRAM: RwLock<Option<String>> = RwLock::new(None);

/// Terminal identity variables a pane may inherit from this process that no longer
/// describe the attached client. Applications read these to select graphics protocols,
/// so a value from the wrong terminal is worse than no value at all.
///
/// `LC_TERMINAL` matters most for remote attach: SSH forwards `LC_*` by default, so it
/// reaches a remote server naming the *local* terminal that started the bridge.
///
/// `HERDR_OUTER_TERM_PROGRAM` is this server's own launch seed. It outranks `TERM_PROGRAM`
/// in `launched_outer_term_program`, so leaving it set would make a nested persistent herdr
/// started inside a pane prefer the outer server's identity over the one this pane carries.
const STALE_TERMINAL_IDENTITY_ENV: [&str; 13] = [
OUTER_TERM_PROGRAM_ENV_VAR,
"TERM_PROGRAM_VERSION",
"TERM_SESSION_ID",
"LC_TERMINAL",
"LC_TERMINAL_VERSION",
"KITTY_WINDOW_ID",
"KITTY_PID",
"WEZTERM_PANE",
"WEZTERM_UNIX_SOCKET",
"ITERM_SESSION_ID",
"ITERM_PROFILE",
"GHOSTTY_RESOURCES_DIR",
"ALACRITTY_WINDOW_ID",
];

/// Set on a server started for a specific outer terminal that the server process itself
/// cannot see. A remote server is started over `ssh -T`, which requests no pty and so
/// forwards neither `TERM_PROGRAM` nor `TERM`.
pub(crate) const OUTER_TERM_PROGRAM_ENV_VAR: &str = "HERDR_OUTER_TERM_PROGRAM";

/// The outer terminal identity available when this process was launched.
///
/// A locally spawned daemon inherits it from the client that started it, so its own
/// environment is correct at that moment; a remote server is told explicitly.
pub(crate) fn launched_outer_term_program() -> Option<String> {
std::env::var(OUTER_TERM_PROGRAM_ENV_VAR)
.ok()
.filter(|value| !value.is_empty())
.or_else(crate::terminal_notify::outer_term_program)
}

pub(crate) fn set_outer_term_program(term_program: Option<String>) {
// A poisoned lock must not strand panes on a departed client's identity.
*OUTER_TERM_PROGRAM
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = term_program;
}

/// Serializes tests that read or write the process-wide outer terminal identity, and
/// resets it so each starts from a known state regardless of test order.
#[cfg(test)]
pub(crate) fn outer_term_program_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let guard = LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
set_outer_term_program(None);
guard
}

#[cfg(test)]
pub(crate) fn outer_term_program_for_test() -> Option<String> {
OUTER_TERM_PROGRAM
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}

fn apply_pane_terminal_env(cmd: &mut CommandBuilder) {
// Each pane is rendered by herdr's own terminal layer, not the outer terminal
// that launched the app. Advertising the inherited TERM leaks the host terminal
// identity into shells and across SSH, which breaks redraw and cursor movement
// when the remote side lacks matching terminfo entries.
cmd.env("TERM", PANE_TERM);
cmd.env("COLORTERM", PANE_COLORTERM);

// TERM_PROGRAM names the emulator that ultimately displays this pane, which is the
// client's outer terminal rather than herdr's virtual one. Applications use it to
// pick an inline image protocol, so it must track the attached client.
match OUTER_TERM_PROGRAM
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_deref()
{
Some(term_program) => cmd.env("TERM_PROGRAM", term_program),
None => cmd.env_remove("TERM_PROGRAM"),
}
for key in STALE_TERMINAL_IDENTITY_ENV {
cmd.env_remove(key);
}
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
Expand Down Expand Up @@ -2974,6 +3067,12 @@ mod tests {
assert!(!process_alive_for_shutdown(43, 42, false, |_| false));
}

/// Shared with the server tests, which exercise the same process-wide identity.
#[cfg(unix)]
fn outer_term_program_lock() -> std::sync::MutexGuard<'static, ()> {
super::outer_term_program_test_lock()
}

#[cfg(unix)]
fn capture_shell_output(command: &str, extra_env: &[(&str, &str)]) -> String {
let pair = native_pty_system()
Expand All @@ -2998,6 +3097,12 @@ mod tests {
cmd.cwd(std::env::current_dir().unwrap());
cmd.env("TERM", "xterm-ghostty");
cmd.env("COLORTERM", "falsecolor");
// Stand in for the terminal identity a persistent server inherits from whichever
// terminal started it, which is not necessarily the attached client's.
cmd.env("TERM_PROGRAM", "stale-outer");
cmd.env("KITTY_WINDOW_ID", "99");
// The launch seed a bridged or locally spawned server carries in its own environment.
cmd.env(OUTER_TERM_PROGRAM_ENV_VAR, "stale-launcher");
apply_pane_terminal_env(&mut cmd);
for (key, value) in extra_env {
cmd.env(key, value);
Expand Down Expand Up @@ -3288,20 +3393,64 @@ mod tests {
#[cfg(unix)]
#[test]
fn pane_terminal_identity_overrides_outer_terminal_env() {
let _guard = outer_term_program_lock();
let output = capture_shell_output("printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"", &[]);
assert_eq!(output, "xterm-256color\ntruecolor\n");
}

#[cfg(unix)]
#[test]
fn pane_terminal_identity_allows_explicit_override() {
let _guard = outer_term_program_lock();
let output = capture_shell_output(
"printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"",
&[("TERM", "vt100"), ("COLORTERM", "24bit")],
);
assert_eq!(output, "vt100\n24bit\n");
}

#[cfg(unix)]
#[test]
fn pane_term_program_reports_attached_client_terminal() {
let _guard = outer_term_program_lock();
set_outer_term_program(Some("kitty".to_string()));
let output = capture_shell_output(
"printf '%s\\n%s\\n%s\\n' \"$TERM_PROGRAM\" \"$TERM\" \"$KITTY_WINDOW_ID\"",
&[],
);
// TERM stays herdr's own virtual terminal; only the outer identity is propagated,
// and the server's own stale KITTY_WINDOW_ID does not leak through.
assert_eq!(output, "kitty\nxterm-256color\n\n");
}

#[cfg(unix)]
#[test]
fn pane_does_not_inherit_the_servers_launch_seed() {
let _guard = outer_term_program_lock();
set_outer_term_program(Some("ghostty".to_string()));
let output = capture_shell_output(
&format!("printf '%s\\n%s\\n' \"$TERM_PROGRAM\" \"${OUTER_TERM_PROGRAM_ENV_VAR}\""),
&[],
);
// launched_outer_term_program prefers the seed over TERM_PROGRAM, so a nested
// persistent herdr started in this pane would otherwise report the outer server's
// terminal instead of the client actually displaying the pane.
assert_eq!(output, "ghostty\n\n");
}

#[cfg(unix)]
#[test]
fn pane_term_program_is_cleared_without_an_attached_client() {
let _guard = outer_term_program_lock();
let output = capture_shell_output(
"printf '%s\\n%s\\n' \"$TERM_PROGRAM\" \"$KITTY_WINDOW_ID\"",
&[],
);
// A stale identity is worse than none: it makes applications pick a graphics
// protocol the real outer terminal may not support.
assert_eq!(output, "\n\n");
}

#[cfg(unix)]
#[tokio::test]
async fn handoff_history_ansi_captures_primary_screen() {
Expand Down
12 changes: 11 additions & 1 deletion src/protocol/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------

/// Current protocol version. Bumped when wire format changes incompatibly.
pub const PROTOCOL_VERSION: u32 = 18;
pub const PROTOCOL_VERSION: u32 = 19;

/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
Expand Down Expand Up @@ -334,6 +334,10 @@ pub enum ClientMessage {
keybindings: ClientKeybindings,
/// Whether this connection will render the full app or attach directly to a pane terminal.
launch_mode: ClientLaunchMode,
/// Identity of the outer terminal emulator that renders this client's output, or
/// `None` when it could not be determined. The server is a persistent process whose
/// own environment may predate this client, so panes cannot infer it locally.
term_program: Option<String>,
},

/// Raw input bytes read from the client's stdin.
Expand Down Expand Up @@ -977,6 +981,7 @@ mod tests {
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
launch_mode: ClientLaunchMode::App,
term_program: Some("ghostty".to_string()),
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
Expand Down Expand Up @@ -1014,6 +1019,7 @@ mod tests {
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
launch_mode: ClientLaunchMode::App,
term_program: None,
}),
0
);
Expand Down Expand Up @@ -1469,6 +1475,7 @@ mod tests {
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
launch_mode: ClientLaunchMode::App,
term_program: None,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
Expand Down Expand Up @@ -1543,6 +1550,7 @@ mod tests {
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
launch_mode: ClientLaunchMode::App,
term_program: None,
},
1 => ClientMessage::Input {
data: vec![(i % 256) as u8; (i as usize % 50) + 1],
Expand Down Expand Up @@ -1979,6 +1987,7 @@ mod tests {
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
launch_mode: ClientLaunchMode::App,
term_program: None,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
Expand Down Expand Up @@ -2015,6 +2024,7 @@ mod tests {
requested_encoding: RenderEncoding::SemanticFrame,
keybindings: ClientKeybindings::Server,
launch_mode: ClientLaunchMode::App,
term_program: None,
},
ClientMessage::Input {
data: b"hello world".to_vec(),
Expand Down
Loading
Loading