From 03fdbc1ac06deeed4f706b93b8b2b176c4a3408c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 16 Aug 2026 01:37:53 +0300 Subject: [PATCH 1/2] feat: refresh pane environment on local attach refs #2448 --- docs/next/CHANGELOG.md | 1 + .../src/content/docs/configuration.mdx | 13 +++ .../website/src/data/config-reference.json | 6 ++ src/app/creation.rs | 1 + src/app/ids.rs | 8 +- src/app/mod.rs | 83 ++++++++++++++++++- src/app/popup.rs | 4 +- src/client/mod.rs | 24 +++++- src/config.rs | 1 + src/config/model.rs | 56 ++++++++++++- src/main.rs | 3 + src/pane.rs | 51 ++++++++++-- src/persist/restore.rs | 1 + src/protocol/wire.rs | 12 +++ src/server/autodetect.rs | 1 + src/server/client_transport.rs | 18 ++++ src/server/handoff.rs | 20 ++++- src/server/headless.rs | 22 +++++ src/server/headless/tests/pane_graphics.rs | 1 + src/workspace.rs | 40 ++++++--- tests/cross_area.rs | 1 + tests/multi_client.rs | 1 + tests/server_headless.rs | 1 + tests/support/mod.rs | 1 + 24 files changed, 342 insertions(+), 28 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 1ded91d034..831ceda0d8 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - CLI help now points coding agents to Herdr's plain-text guide, documentation index, and built-in control skill. - Added Qwen Code detection for idle, working, and user-confirmation states, plus optional native session restore. +- Local clients now refresh a configurable allowlist of desktop and authentication environment variables when they attach, so processes launched afterward can use the current Wayland, X11, and SSH session environment. (#2448) - Herdr now keeps the outer terminal window title in sync with the session through `ui.window_title`, so window managers and terminal tab bars show the active workspace and the host the panes actually run on. - The desktop tab bar now has configurable right-aligned status entries for zoom state, hostname, date/time, literal text, and asynchronously refreshed command output. - Optional `keys.move_tab_previous` and `keys.move_tab_next` bindings now reorder the active tab in place, wrapping at either end. diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 4138c9f5bc..61c148651b 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -491,6 +491,19 @@ resume_agents_on_restore = true Only panes with a valid native session reference from an official integration can resume; other panes restore as normal shells. See [Session state and restore](/docs/session-state/) for supported Agents and persistence behavior. +## Environment refresh on attach + +When a local client attaches, Herdr refreshes a tmux-style allowlist of desktop and authentication variables for processes launched afterward. Running processes keep their existing environment. This lets a server started outside a graphical session launch new panes with current values such as `WAYLAND_DISPLAY`, `DISPLAY`, and `SSH_AUTH_SOCK`. + +Customize the allowlist under `[session]`: + +```toml +[session] +update_environment = ["WAYLAND_DISPLAY", "DISPLAY", "SSH_AUTH_SOCK", "HYPRLAND_INSTANCE_SIGNATURE"] +``` + +Missing variables are removed from future process environments. Remote clients do not update the remote server from the local environment. `TERM`, `COLORTERM`, `CODEX_THREAD_ID`, and `HERDR_*` variables are always managed by Herdr and cannot be added to this list. + ## IME cursor tracking On macOS, AI Agent TUIs that hide the hardware cursor can prevent native input-method candidate windows from following the focused pane. Reveal a cursor anchor for those panes with: diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index aa5b7ed0ea..e03f42f727 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -1162,6 +1162,12 @@ "type": "boolean", "default": "true", "description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session." + }, + { + "key": "session.update_environment", + "type": "array of strings", + "default": "[\"DISPLAY\", \"KRB5CCNAME\", \"MSYSTEM\", \"SSH_ASKPASS\", \"SSH_AUTH_SOCK\", \"SSH_AGENT_PID\", \"SSH_CONNECTION\", \"WAYLAND_DISPLAY\", \"WINDOWID\", \"XAUTHORITY\", \"XDG_CURRENT_DESKTOP\", \"XDG_SESSION_DESKTOP\", \"XDG_SESSION_TYPE\"]", + "description": "Refresh selected environment variables from each local client for processes launched after it attaches. Running processes are unchanged." } ] }, diff --git a/src/app/creation.rs b/src/app/creation.rs index d531e73370..a9d511474f 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -256,6 +256,7 @@ impl App { self.render_notify.clone(), self.render_dirty.clone(), extra_env, + &self.session_environment, )?; self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); diff --git a/src/app/ids.rs b/src/app/ids.rs index c2fc0a4d96..b4ef61af37 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -49,11 +49,9 @@ impl App { let tab_id = self.public_tab_id(ws_idx, tab_idx)?; let pane_id = self.public_pane_id(ws_idx, pane_id)?; Some( - crate::pane::PaneLaunchEnv::from_extra(extra_env).with_identity( - workspace_id, - tab_id, - pane_id, - ), + crate::pane::PaneLaunchEnv::from_extra(extra_env) + .with_session(&self.session_environment) + .with_identity(workspace_id, tab_id, pane_id), ) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 3524411530..192cbc21b9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -30,7 +30,7 @@ mod theme_sync; mod window_title; mod worktrees; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::future::pending; use std::io::{self, Write}; use std::sync::Arc; @@ -161,6 +161,8 @@ pub struct App { /// even when an App-internal drain consumes the event before the forwarding drain. pub(crate) local_input_source_switch: bool, pub(crate) config_reloaded_from_disk: bool, + pub(crate) session_environment: crate::pane::SessionEnvironment, + session_update_environment: Vec, prefix_input_source: Box, } @@ -233,6 +235,16 @@ fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> boo auto_updates_enabled(no_session) && check_enabled } +fn filtered_session_update_environment(config: &crate::config::Config) -> Vec { + config + .session + .update_environment + .iter() + .filter(|name| crate::config::session_environment_name_allowed(name)) + .cloned() + .collect() +} + fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry { if no_session { return std::collections::HashMap::new(); @@ -797,6 +809,8 @@ impl App { local_terminal_notifications: true, local_input_source_switch: true, config_reloaded_from_disk: false, + session_environment: Vec::new(), + session_update_environment: filtered_session_update_environment(config), prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()), }; app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator); @@ -1378,6 +1392,30 @@ impl App { self.apply_config_from_disk(true) } + pub(crate) fn update_session_environment(&mut self, update: Vec<(String, Option)>) { + let allowed = |name: &str| { + self.session_update_environment + .iter() + .any(|candidate| candidate == name) + && crate::config::session_environment_name_allowed(name) + }; + self.session_environment = update + .into_iter() + .filter(|(name, value)| { + allowed(name) && value.as_ref().is_none_or(|v| !v.contains('\0')) + }) + .collect::>() + .into_iter() + .collect(); + self.sync_session_environment_to_workspaces(); + } + + fn sync_session_environment_to_workspaces(&mut self) { + for workspace in &mut self.state.workspaces { + workspace.set_session_environment(&self.session_environment); + } + } + pub(crate) fn take_config_reloaded_from_disk(&mut self) -> bool { let reloaded = self.config_reloaded_from_disk; self.config_reloaded_from_disk = false; @@ -1441,6 +1479,14 @@ impl App { } } + if !invalid_section("session") { + self.session_update_environment = filtered_session_update_environment(config); + let allowed = &self.session_update_environment; + self.session_environment + .retain(|(name, _)| allowed.iter().any(|candidate| candidate == name)); + self.sync_session_environment_to_workspaces(); + } + if !invalid_section("ui") { // Validate sidebar bounds before they reach any `u16::clamp` call. // On `min > max`, treat the entire `[ui]` section as invalid: keep @@ -2090,6 +2136,41 @@ mod tests { ) } + #[test] + fn session_environment_update_filters_and_replaces_the_overlay() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.session_update_environment = vec![ + "DISPLAY".into(), + "SSH_AUTH_SOCK".into(), + "TERM".into(), + "HERDR_SOCKET_PATH".into(), + ]; + + app.update_session_environment(vec![ + ("DISPLAY".into(), Some("client-display".into())), + ("DISPLAY".into(), Some("latest-display".into())), + ("SSH_AUTH_SOCK".into(), None), + ("TERM".into(), Some("unsafe-term".into())), + ("HERDR_SOCKET_PATH".into(), Some("unsafe-socket".into())), + ("UNLISTED".into(), Some("ignored".into())), + ]); + + let expected = vec![ + ("DISPLAY".into(), Some("latest-display".into())), + ("SSH_AUTH_SOCK".into(), None), + ]; + assert_eq!(app.session_environment, expected); + assert_eq!(app.state.workspaces[0].session_environment, expected); + + app.update_session_environment(vec![("DISPLAY".into(), None)]); + assert_eq!(app.session_environment, vec![("DISPLAY".into(), None)]); + assert_eq!( + app.state.workspaces[0].session_environment, + app.session_environment + ); + } + fn unique_temp_path(name: &str) -> std::path::PathBuf { let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src/app/popup.rs b/src/app/popup.rs index c5877987de..176c475a49 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -153,7 +153,9 @@ impl App { let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let pane_id = PaneId::alloc(); let terminal_id = TerminalId::alloc(); - let launch_env = PaneLaunchEnv::from_extra(extra_env).without_pane_identity(); + let launch_env = PaneLaunchEnv::from_extra(extra_env) + .with_session(&self.session_environment) + .without_pane_identity(); let terminal_area = if self.state.view.terminal_area.width >= 4 && self.state.view.terminal_area.height >= 4 { diff --git a/src/client/mod.rs b/src/client/mod.rs index c33157fb0d..cff6f359d6 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -702,6 +702,26 @@ fn is_remote_client_process() -> bool { std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() } +fn requested_environment_update() -> Option)>> { + if is_remote_client_process() { + return None; + } + let config = crate::config::Config::load().config; + let mut update = config + .session + .update_environment + .into_iter() + .filter(|name| crate::config::session_environment_name_allowed(name)) + .map(|name| { + let value = std::env::var_os(&name).and_then(|value| value.into_string().ok()); + (name, value) + }) + .collect::>(); + update.sort_by(|left, right| left.0.cmp(&right.0)); + update.dedup_by(|left, right| left.0 == right.0); + Some(update) +} + /// Time to wait for the server's Welcome reply during the handshake. /// /// A local client talks to an already-connected server, so 5s is plenty. The @@ -850,6 +870,7 @@ fn do_handshake( cell_width_px, cell_height_px, ), + environment_update: requested_environment_update(), }; protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; @@ -2742,11 +2763,12 @@ mod tests { } #[test] - fn remote_client_uses_extended_handshake_timeout() { + fn remote_client_uses_extended_handshake_timeout_without_forwarding_environment() { let _guard = env_lock().lock().unwrap(); let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); + assert!(requested_environment_update().is_none()); } #[test] diff --git a/src/config.rs b/src/config.rs index 0595ae2f76..06ff24cdb9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,7 @@ pub use self::{ }; pub(crate) use self::keybinds::parse_key_combo; +pub(crate) use self::model::session_environment_name_allowed; pub(crate) use self::{ io::upsert_top_level_bool, tab_bar::{ diff --git a/src/config/model.rs b/src/config/model.rs index ad779d263b..4833a05eb8 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -271,16 +271,51 @@ pub struct SessionConfig { /// Resume supported AI-agent panes into their native conversation sessions /// when restoring a Herdr session. Default: true. pub resume_agents_on_restore: bool, + /// Client environment variables refreshed for processes launched after attachment. + pub update_environment: Vec, } impl Default for SessionConfig { fn default() -> Self { Self { resume_agents_on_restore: true, + update_environment: default_update_environment(), } } } +pub(crate) fn session_environment_name_allowed(name: &str) -> bool { + !name.is_empty() + && !name.contains(['=', '\0']) + && !name.eq_ignore_ascii_case("TERM") + && !name.eq_ignore_ascii_case("COLORTERM") + && !name.eq_ignore_ascii_case("CODEX_THREAD_ID") + && !name + .get(..6) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("HERDR_")) +} + +fn default_update_environment() -> Vec { + [ + "DISPLAY", + "KRB5CCNAME", + "MSYSTEM", + "SSH_ASKPASS", + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "SSH_CONNECTION", + "WAYLAND_DISPLAY", + "WINDOWID", + "XAUTHORITY", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ConfigReloadStatus { @@ -1316,16 +1351,35 @@ new_cwd = "~/Projects" } #[test] - fn resume_agents_on_restore_defaults_on_and_parses() { + fn session_config_defaults_and_parses() { let default_config = Config::default(); assert!(default_config.session.resume_agents_on_restore); + assert!(default_config + .session + .update_environment + .contains(&"WAYLAND_DISPLAY".to_string())); let toml = r#" [session] resume_agents_on_restore = false +update_environment = ["DISPLAY", "NIRI_SOCKET"] "#; let config: Config = toml::from_str(toml).unwrap(); assert!(!config.session.resume_agents_on_restore); + assert_eq!( + config.session.update_environment, + vec!["DISPLAY", "NIRI_SOCKET"] + ); + } + + #[test] + fn session_environment_rejects_terminal_and_managed_names() { + assert!(session_environment_name_allowed("WAYLAND_DISPLAY")); + assert!(!session_environment_name_allowed("TERM")); + assert!(!session_environment_name_allowed("colorterm")); + assert!(!session_environment_name_allowed("HERDR_SOCKET_PATH")); + assert!(!session_environment_name_allowed("herdr_custom")); + assert!(!session_environment_name_allowed("CODEX_THREAD_ID")); } #[test] diff --git a/src/main.rs b/src/main.rs index d2fc059526..705ccf7173 100644 --- a/src/main.rs +++ b/src/main.rs @@ -422,6 +422,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Resume supported AI-agent panes into their native conversation sessions after # a Herdr server restart. Requires official integrations that report session refs. # resume_agents_on_restore = true +# Refresh selected environment variables from each local client when it attaches. +# Updated values apply only to processes launched afterward, not running panes. +# update_environment = ["DISPLAY", "KRB5CCNAME", "MSYSTEM", "SSH_ASKPASS", "SSH_AUTH_SOCK", "SSH_AGENT_PID", "SSH_CONNECTION", "WAYLAND_DISPLAY", "WINDOWID", "XAUTHORITY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE"] [remote] # Whether herdr manages the ssh config used for `herdr --remote`. diff --git a/src/pane.rs b/src/pane.rs index a47e62eb12..8e425ba388 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -78,8 +78,11 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { cmd.env("COLORTERM", PANE_COLORTERM); } +pub(crate) type SessionEnvironment = Vec<(String, Option)>; + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct PaneLaunchEnv { + session: SessionEnvironment, extra: Vec<(String, String)>, identity: PaneLaunchIdentity, } @@ -99,11 +102,17 @@ enum PaneLaunchIdentity { impl PaneLaunchEnv { pub(crate) fn from_extra(extra: Vec<(String, String)>) -> Self { Self { + session: Vec::new(), extra, identity: PaneLaunchIdentity::Inherit, } } + pub(crate) fn with_session(mut self, session: &[(String, Option)]) -> Self { + self.session = session.to_vec(); + self + } + pub(crate) fn with_identity( mut self, workspace_id: String, @@ -125,10 +134,17 @@ impl PaneLaunchEnv { } fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) { - cmd.env_remove("CODEX_THREAD_ID"); + for (key, value) in &launch_env.session { + match value { + Some(value) => cmd.env(key, value), + None => cmd.env_remove(key), + } + } for (key, value) in &launch_env.extra { cmd.env(key, value); } + cmd.env_remove("CODEX_THREAD_ID"); + apply_pane_terminal_env(cmd); cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); crate::integration::apply_pane_base_env(cmd); crate::platform::apply_pane_runtime_marker(cmd); @@ -1749,7 +1765,6 @@ impl PaneRuntime { uses_windows_powershell_pane_shell(shell_config); let mut cmd = pane_shell_command_builder(shell_config)?; cmd.cwd(cwd); - apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, @@ -1791,7 +1806,6 @@ impl PaneRuntime { ) -> std::io::Result { let mut cmd = crate::platform::pane_custom_command_pty_builder(command); cmd.cwd(cwd); - apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, @@ -1838,7 +1852,6 @@ impl PaneRuntime { cmd.arg(arg); } cmd.cwd(cwd); - apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, @@ -3077,13 +3090,37 @@ mod tests { use super::*; #[test] - fn pane_launch_env_removes_outer_codex_thread_id() { + fn pane_launch_env_applies_session_and_explicit_precedence() { let mut cmd = CommandBuilder::new("shell"); + cmd.env("DISPLAY", "daemon-display"); + cmd.env("SSH_AUTH_SOCK", "daemon-socket"); cmd.env("CODEX_THREAD_ID", "outer-session"); + let launch_env = PaneLaunchEnv::from_extra(vec![ + ("DISPLAY".into(), "explicit-display".into()), + ("TERM".into(), "unsafe-term".into()), + ("HERDR_ENV".into(), "unsafe-herdr".into()), + ]) + .with_session(&[ + ("DISPLAY".into(), Some("client-display".into())), + ("SSH_AUTH_SOCK".into(), None), + ]); + + apply_pane_launch_env(&mut cmd, &launch_env); - apply_pane_launch_env(&mut cmd, &PaneLaunchEnv::default()); - + assert_eq!( + cmd.get_env("DISPLAY").and_then(std::ffi::OsStr::to_str), + Some("explicit-display") + ); + assert!(cmd.get_env("SSH_AUTH_SOCK").is_none()); assert!(cmd.get_env("CODEX_THREAD_ID").is_none()); + assert_eq!( + cmd.get_env("TERM").and_then(std::ffi::OsStr::to_str), + Some(PANE_TERM) + ); + assert_eq!( + cmd.get_env("HERDR_ENV").and_then(std::ffi::OsStr::to_str), + Some(crate::HERDR_ENV_VALUE) + ); } #[tokio::test] diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 3c5775c9e1..6f8db4ef03 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -423,6 +423,7 @@ fn restore_workspace( public_pane_numbers, next_public_pane_number, next_public_tab_number, + session_environment: Vec::new(), active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)), tabs, #[cfg(test)] diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 4a445361f5..2aa2722bd6 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -359,6 +359,9 @@ pub enum ClientMessage { keybindings: ClientKeybindings, /// Whether this connection will render the full app or attach directly to a pane terminal. launch_mode: ClientLaunchMode, + /// Allowlisted local environment values for later process launches. + /// None means this connection must not update the session environment. + environment_update: Option)>>, }, /// Raw input bytes read from the client's stdin. @@ -1042,6 +1045,10 @@ mod tests { requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, + environment_update: Some(vec![ + ("WAYLAND_DISPLAY".into(), Some("wayland-1".into())), + ("SSH_AUTH_SOCK".into(), None), + ]), }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -1079,6 +1086,7 @@ mod tests { requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, + environment_update: None, }), 0 ); @@ -1660,6 +1668,7 @@ mod tests { requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, + environment_update: None, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1734,6 +1743,7 @@ mod tests { requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, + environment_update: None, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], @@ -2170,6 +2180,7 @@ mod tests { requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, + environment_update: None, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -2206,6 +2217,7 @@ mod tests { requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, + environment_update: None, }, ClientMessage::Input { data: b"hello world".to_vec(), diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index 337c388f9a..4162e73df1 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -126,6 +126,7 @@ fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result { requested_encoding: crate::protocol::RenderEncoding::SemanticFrame, keybindings: crate::protocol::ClientKeybindings::Server, launch_mode: crate::protocol::ClientLaunchMode::App, + environment_update: None, }; match crate::protocol::write_message(&mut stream, &hello) { diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index f1237c8907..2997b0ba1f 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -316,6 +316,7 @@ pub(crate) enum ServerEvent { keybindings: Option>, direct_attach_requested: bool, direct_graphics: bool, + environment_update: Option)>>, writer: ClientWriter, }, /// A client sent an input message. @@ -563,6 +564,7 @@ pub(crate) fn handle_client_handshake( keybindings, direct_attach_requested, direct_graphics, + environment_update, ) = match hello { ClientMessage::Hello { version, @@ -573,6 +575,7 @@ pub(crate) fn handle_client_handshake( requested_encoding, keybindings, launch_mode, + environment_update, } => { // Version check. match protocol::check_client_version(version) { @@ -613,6 +616,7 @@ pub(crate) fn handle_client_handshake( keybindings, launch_mode == ClientLaunchMode::TerminalAttach, launch_mode == ClientLaunchMode::AppDirectGraphics, + environment_update, ) } _ => { @@ -677,6 +681,7 @@ pub(crate) fn handle_client_handshake( keybindings, direct_attach_requested, direct_graphics, + environment_update, writer, }; if let Err(err) = server_event_tx.blocking_send(connected) { @@ -1330,6 +1335,10 @@ new_tab = "ctrl+notakey" requested_encoding: RenderEncoding::TerminalAnsi, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, + environment_update: Some(vec![ + ("WAYLAND_DISPLAY".into(), Some("wayland-1".into())), + ("SSH_AUTH_SOCK".into(), None), + ]), }, ) .expect("write hello"); @@ -1363,6 +1372,7 @@ new_tab = "ctrl+notakey" keybindings, direct_attach_requested, direct_graphics, + environment_update, writer, } => { assert_eq!(client_id, 42); @@ -1372,6 +1382,13 @@ new_tab = "ctrl+notakey" assert!(keybindings.is_none()); assert!(!direct_attach_requested); assert!(!direct_graphics); + assert_eq!( + environment_update, + Some(vec![ + ("WAYLAND_DISPLAY".into(), Some("wayland-1".into())), + ("SSH_AUTH_SOCK".into(), None), + ]) + ); drop(writer); } other => panic!("expected ClientConnected, got {other:?}"), @@ -1407,6 +1424,7 @@ new_tab = "ctrl+notakey" requested_encoding: RenderEncoding::TerminalAnsi, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::TerminalAttach, + environment_update: None, }, ) .expect("write hello"); diff --git a/src/server/handoff.rs b/src/server/handoff.rs index 2cd17144e0..132d03b31f 100644 --- a/src/server/handoff.rs +++ b/src/server/handoff.rs @@ -44,6 +44,9 @@ pub(crate) struct HandoffManifest { /// Absent from manifests written before this field existed. #[serde(default)] pub api_window_title: Option, + /// Attachment-refreshed environment used only for later process launches. + #[serde(default)] + pub session_environment: crate::pane::SessionEnvironment, } #[cfg(unix)] @@ -310,6 +313,7 @@ pub(crate) fn manifest_for( expected_protocol: Option, expected_version: Option, api_window_title: Option, + session_environment: crate::pane::SessionEnvironment, ) -> HandoffManifest { HandoffManifest { version: HANDOFF_VERSION, @@ -320,6 +324,7 @@ pub(crate) fn manifest_for( snapshot, panes, api_window_title, + session_environment, } } @@ -489,36 +494,47 @@ mod tests { } #[test] - fn a_handoff_carries_an_api_set_window_title() { + fn a_handoff_carries_runtime_session_state() { let manifest = manifest_for( empty_snapshot(), Vec::new(), None, None, Some("deploying".to_string()), + vec![("WAYLAND_DISPLAY".to_string(), Some("wayland-1".to_string()))], ); assert_eq!(manifest.api_window_title.as_deref(), Some("deploying")); + assert_eq!( + manifest.session_environment, + vec![("WAYLAND_DISPLAY".to_string(), Some("wayland-1".to_string()))] + ); } #[test] - fn a_manifest_written_before_the_title_field_still_loads() { + fn an_older_manifest_still_loads_without_new_runtime_fields() { let manifest = manifest_for( empty_snapshot(), Vec::new(), None, None, Some("deploying".to_string()), + Vec::new(), ); let mut value = serde_json::to_value(&manifest).expect("manifest should serialize"); value .as_object_mut() .expect("manifest should be a json object") .remove("api_window_title"); + value + .as_object_mut() + .expect("manifest should be a json object") + .remove("session_environment"); let older: HandoffManifest = serde_json::from_value(value).expect("an older manifest should still load"); assert!(older.api_window_title.is_none()); + assert!(older.session_environment.is_empty()); } } diff --git a/src/server/headless.rs b/src/server/headless.rs index 354b0008dd..39309fdf98 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1309,6 +1309,7 @@ impl HeadlessServer { params.expected_protocol, params.expected_version, self.api_window_title.clone(), + self.app.session_environment.clone(), ); let mut import_child = match crate::server::handoff::spawn_handoff_import( import_exe.as_deref(), @@ -2991,6 +2992,7 @@ impl HeadlessServer { render_encoding, direct_attach_requested, direct_graphics, + environment_update, } => { if self.handoff_in_progress { if let Ok(message) = @@ -3034,6 +3036,9 @@ impl HeadlessServer { connection.direct_graphics = direct_graphics; connection.pixel_mouse = direct_graphics; self.clients.insert(client_id, connection); + if let Some(environment_update) = environment_update { + self.app.update_session_environment(environment_update); + } if !direct_attach_requested { self.foreground_client_id = Some(client_id); } @@ -5143,6 +5148,7 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> &received.manifest.snapshot, &mut imports, )?; + app.update_session_environment(received.manifest.session_environment.clone()); app.state.local_sound_playback = false; app.local_terminal_notifications = false; app.local_input_source_switch = false; @@ -6116,6 +6122,7 @@ mod tests { keybindings: None, direct_attach_requested: false, direct_graphics: true, + environment_update: None, writer: writer_a, })); assert!(server.clients[&1].direct_graphics); @@ -6133,6 +6140,7 @@ mod tests { keybindings: None, direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer: writer_b, })); assert!(!server.direct_graphics_available()); @@ -6163,6 +6171,7 @@ new_tab = "prefix+t" keybindings: Some(Box::new(local_keybindings)), direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer: writer_a, })); assert_eq!( @@ -6188,6 +6197,7 @@ new_tab = "prefix+t" keybindings: None, direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer: writer_b, })); assert_eq!( @@ -6229,6 +6239,7 @@ new_tab = "prefix+t" keybindings: Some(Box::new(local_keybindings)), direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer: writer_a, })); assert_eq!(server.app.state.config_diagnostic, without_keybindings); @@ -6243,6 +6254,7 @@ new_tab = "prefix+t" keybindings: None, direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer: writer_b, })); assert_eq!( @@ -6287,6 +6299,7 @@ next_tab = "" keybindings: Some(Box::new(local_keybindings)), direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer, })); server.app.state.mode = crate::app::Mode::Settings; @@ -6363,6 +6376,7 @@ next_tab = "" keybindings: Some(Box::new(local_config.live_keybinds().unwrap())), direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer: writer_a, })); server.app.state.mode = crate::app::Mode::Settings; @@ -6384,6 +6398,7 @@ next_tab = "" keybindings: None, direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer: writer_b, })); assert_eq!( @@ -6419,6 +6434,7 @@ next_tab = "" keybindings: None, direct_attach_requested: true, direct_graphics: false, + environment_update: None, writer, })); assert!(server.clients.contains_key(&7)); @@ -6485,6 +6501,7 @@ next_tab = "" keybindings: None, direct_attach_requested: true, direct_graphics: false, + environment_update: None, writer, })); control_rx @@ -6899,6 +6916,7 @@ next_tab = "" keybindings: None, direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer, })); @@ -6934,6 +6952,7 @@ next_tab = "" keybindings: None, direct_attach_requested: true, direct_graphics: false, + environment_update: None, writer, })); @@ -6968,6 +6987,7 @@ next_tab = "" keybindings: None, direct_attach_requested: false, direct_graphics: false, + environment_update: None, writer, })); assert!(server.has_app_client()); @@ -7069,6 +7089,7 @@ next_tab = "" keybindings: None, direct_attach_requested: true, direct_graphics: false, + environment_update: None, writer, })); assert!( @@ -9094,6 +9115,7 @@ next_tab = "" keybindings: None, direct_attach_requested: true, direct_graphics: false, + environment_update: None, writer, })); assert!( diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index bd6e3cd7c6..ba5ed2bf30 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -318,6 +318,7 @@ fn direct_eligibility_is_installed_with_the_client_connection() { keybindings: None, direct_attach_requested: false, direct_graphics: true, + environment_update: None, writer, })); diff --git a/src/workspace.rs b/src/workspace.rs index 0a719ab1d1..11c64b4b99 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -202,6 +202,7 @@ pub struct Workspace { pub public_pane_numbers: HashMap, pub(crate) next_public_pane_number: usize, pub(crate) next_public_tab_number: usize, + pub(crate) session_environment: crate::pane::SessionEnvironment, pub tabs: Vec, pub active_tab: usize, #[cfg(test)] @@ -267,6 +268,7 @@ impl Workspace { public_pane_numbers, next_public_pane_number: 2, next_public_tab_number: 2, + session_environment: Vec::new(), tabs: vec![tab], active_tab: 0, #[cfg(test)] @@ -301,6 +303,7 @@ impl Workspace { render_notify, render_dirty, Vec::new(), + &[], ) } @@ -317,6 +320,7 @@ impl Workspace { render_notify: Arc, render_dirty: Arc, extra_env: Vec<(String, String)>, + session_environment: &[(String, Option)], ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, @@ -331,6 +335,7 @@ impl Workspace { render_dirty, None, extra_env, + session_environment, ) } @@ -390,6 +395,7 @@ impl Workspace { render_dirty, Some(argv), extra_env, + &[], ) } @@ -407,13 +413,16 @@ impl Workspace { render_dirty: Arc, argv: Option<&[String]>, extra_env: Vec<(String, String)>, + session_environment: &[(String, Option)], ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let id = generate_workspace_id(); - let launch_env = PaneLaunchEnv::from_extra(extra_env).with_identity( - id.clone(), - public_tab_id_for_number(&id, 1), - public_pane_id_for_number(&id, 1), - ); + let launch_env = PaneLaunchEnv::from_extra(extra_env) + .with_session(session_environment) + .with_identity( + id.clone(), + public_tab_id_for_number(&id, 1), + public_pane_id_for_number(&id, 1), + ); let (tab, terminal, runtime) = if let Some(argv) = argv { Tab::new_argv_command( 1, @@ -466,6 +475,7 @@ impl Workspace { public_pane_numbers, next_public_pane_number: 2, next_public_tab_number: 2, + session_environment: session_environment.to_vec(), tabs: vec![tab], active_tab: 0, #[cfg(test)] @@ -1072,11 +1082,20 @@ impl Workspace { pane_number: usize, extra_env: Vec<(String, String)>, ) -> PaneLaunchEnv { - PaneLaunchEnv::from_extra(extra_env).with_identity( - self.id.clone(), - public_tab_id_for_number(&self.id, tab_number), - public_pane_id_for_number(&self.id, pane_number), - ) + PaneLaunchEnv::from_extra(extra_env) + .with_session(&self.session_environment) + .with_identity( + self.id.clone(), + public_tab_id_for_number(&self.id, tab_number), + public_pane_id_for_number(&self.id, pane_number), + ) + } + + pub(crate) fn set_session_environment( + &mut self, + environment: &crate::pane::SessionEnvironment, + ) { + self.session_environment.clone_from(environment); } pub fn public_tab_number(&self, tab_idx: usize) -> Option { @@ -1304,6 +1323,7 @@ impl Workspace { public_pane_numbers, next_public_pane_number: 2, next_public_tab_number: 2, + session_environment: Vec::new(), tabs: vec![tab], active_tab: 0, test_runtimes: HashMap::new(), diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 6f1ab82b22..5c90c19b73 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -429,6 +429,7 @@ fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16) payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App + payload.extend_from_slice(&encode_varint_u32(0)); // environment_update: None stream .write_all(&frame_message(&payload)) diff --git a/tests/multi_client.rs b/tests/multi_client.rs index 20deedaf9f..cbe1386f6b 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -529,6 +529,7 @@ fn client_handshake( &encode_varint_u32(0), // RenderEncoding::SemanticFrame &encode_varint_u32(0), // ClientKeybindings::Server &encode_varint_u32(0), // ClientLaunchMode::App + &encode_varint_u32(0), // environment_update: None ], ); stream diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 5abb5aceea..6c2fc73036 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -181,6 +181,7 @@ fn client_handshake( &encode_varint_u32(0), // RenderEncoding::SemanticFrame &encode_varint_u32(0), // ClientKeybindings::Server &encode_varint_u32(0), // ClientLaunchMode::App + &encode_varint_u32(0), // environment_update: None ], ); let framed = frame_message(&hello_payload); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index f0ed100bcc..394eff0e8f 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -241,6 +241,7 @@ pub fn client_handshake( &encode_varint_u32(0), // RenderEncoding::SemanticFrame &encode_varint_u32(0), // ClientKeybindings::Server &encode_varint_u32(0), // ClientLaunchMode::App + &encode_varint_u32(0), // environment_update: None ], ); let framed = frame_message(&hello_payload); From 3defcef82df8fe5c136ed4501e64424aeaf92495 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 16 Aug 2026 01:57:15 +0300 Subject: [PATCH 2/2] fix: preserve refreshed environment across edge paths refs #2448 --- src/app/api/panes.rs | 14 +++++++++- src/client/mod.rs | 62 +++++++++++++++++++++++++++++++++++------- src/config.rs | 14 ++++++++++ src/config/model.rs | 7 +++++ src/server/handoff.rs | 16 +++++++---- src/server/headless.rs | 6 +++- 6 files changed, 101 insertions(+), 18 deletions(-) diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6615fa0a19..1f848c1613 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -928,7 +928,7 @@ impl App { .map(|terminal| terminal.cwd.clone()) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let moved_pane_id = moved.pane_id; - let workspace = crate::workspace::Workspace::from_existing_pane( + let mut workspace = crate::workspace::Workspace::from_existing_pane( label, tab_label, identity_cwd, @@ -937,6 +937,7 @@ impl App { self.render_notify.clone(), self.render_dirty.clone(), ); + workspace.set_session_environment(&self.session_environment); self.state.workspaces.push(workspace); let target_ws_idx = self.state.workspaces.len() - 1; created_workspace = true; @@ -1068,6 +1069,7 @@ impl App { ); workspace.id = context.previous_workspace_id; workspace.worktree_space = context.previous_worktree_space; + workspace.set_session_environment(&self.session_environment); let insert_idx = context.source_ws_idx.min(self.state.workspaces.len()); if let Some(active) = self.state.active { if active >= insert_idx { @@ -2983,6 +2985,7 @@ mod tests { #[test] fn api_pane_move_to_new_workspace_closes_empty_source_workspace() { let mut app = app_with_linked_worktree(); + app.update_session_environment(vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))]); let source = app.state.workspaces[0].tabs[0].root_pane; let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) @@ -3035,6 +3038,10 @@ mod tests { assert_ne!(move_result.pane.pane_id, source_public); assert_eq!(move_result.pane.terminal_id, source_terminal.to_string()); assert_eq!(app.state.workspaces.len(), 1); + assert_eq!( + app.state.workspaces[0].session_environment, + vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))] + ); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) @@ -3191,6 +3198,7 @@ mod tests { #[test] fn api_pane_move_recovery_restores_removed_source_workspace() { let mut app = app_with_linked_worktree(); + app.update_session_environment(vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))]); let source = app.state.workspaces[0].tabs[0].root_pane; let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) @@ -3216,6 +3224,10 @@ mod tests { assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].id, previous_workspace_id); + assert_eq!( + app.state.workspaces[0].session_environment, + vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))] + ); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) diff --git a/src/client/mod.rs b/src/client/mod.rs index cff6f359d6..f2ac7c7833 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -702,6 +702,23 @@ fn is_remote_client_process() -> bool { std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() } +fn environment_update_entry( + name: String, + value: Option, +) -> Option<(String, Option)> { + match value { + Some(value) => value.into_string().ok().map(|value| (name, Some(value))), + None => Some((name, None)), + } +} + +fn launch_mode_updates_environment(launch_mode: ClientLaunchMode) -> bool { + matches!( + launch_mode, + ClientLaunchMode::App | ClientLaunchMode::AppDirectGraphics + ) +} + fn requested_environment_update() -> Option)>> { if is_remote_client_process() { return None; @@ -712,9 +729,9 @@ fn requested_environment_update() -> Option)>> { .update_environment .into_iter() .filter(|name| crate::config::session_environment_name_allowed(name)) - .map(|name| { - let value = std::env::var_os(&name).and_then(|value| value.into_string().ok()); - (name, value) + .filter_map(|name| { + let value = std::env::var_os(&name); + environment_update_entry(name, value) }) .collect::>(); update.sort_by(|left, right| left.0.cmp(&right.0)); @@ -856,6 +873,12 @@ fn do_handshake( .map_err(ClientError::ConnectionFailed)?; // Send Hello. + let launch_mode = client_launch_mode( + direct_attach_requested, + exact_cell_size, + cell_width_px, + cell_height_px, + ); let hello = ClientMessage::Hello { version: PROTOCOL_VERSION, cols, @@ -864,13 +887,10 @@ fn do_handshake( cell_height_px, requested_encoding, keybindings: requested_keybindings(), - launch_mode: client_launch_mode( - direct_attach_requested, - exact_cell_size, - cell_width_px, - cell_height_px, - ), - environment_update: requested_environment_update(), + launch_mode, + environment_update: launch_mode_updates_environment(launch_mode) + .then(requested_environment_update) + .flatten(), }; protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; @@ -2680,6 +2700,13 @@ mod tests { client_launch_mode(true, false, 8, 16), ClientLaunchMode::TerminalAttach ); + assert!(!launch_mode_updates_environment( + ClientLaunchMode::TerminalAttach + )); + assert!(launch_mode_updates_environment(ClientLaunchMode::App)); + assert!(launch_mode_updates_environment( + ClientLaunchMode::AppDirectGraphics + )); } #[test] @@ -2762,6 +2789,21 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn non_utf8_environment_values_are_not_reported_as_missing() { + use std::os::unix::ffi::OsStringExt; + + assert_eq!( + environment_update_entry("DISPLAY".into(), Some(OsString::from_vec(vec![0xff]))), + None + ); + assert_eq!( + environment_update_entry("DISPLAY".into(), None), + Some(("DISPLAY".into(), None)) + ); + } + #[test] fn remote_client_uses_extended_handshake_timeout_without_forwarding_environment() { let _guard = env_lock().lock().unwrap(); diff --git a/src/config.rs b/src/config.rs index 06ff24cdb9..6861158ede 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,11 +92,25 @@ impl Config { .chain(self.ui.sound.diagnostics()) .chain(tab_bar_right_diagnostics(&self.ui.tab_bar_right)) .chain(window_title_diagnostics(&self.ui.window_title)) + .chain(self.session_environment_diagnostics()) .chain(self.invalid_sidebar_bounds_diagnostic()) .chain(self.invalid_headless_size_diagnostic()) .collect() } + fn session_environment_diagnostics(&self) -> Vec { + self.session + .update_environment + .iter() + .filter(|name| !session_environment_name_allowed(name)) + .map(|name| { + format!( + "session.update_environment entry {name:?} is reserved or invalid; ignoring" + ) + }) + .collect() + } + pub(crate) fn headless_size(&self) -> (u16, u16) { if self.invalid_headless_size_diagnostic().is_some() { (DEFAULT_HEADLESS_COLS, DEFAULT_HEADLESS_ROWS) diff --git a/src/config/model.rs b/src/config/model.rs index 4833a05eb8..d17efba8c3 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1370,6 +1370,7 @@ update_environment = ["DISPLAY", "NIRI_SOCKET"] config.session.update_environment, vec!["DISPLAY", "NIRI_SOCKET"] ); + assert!(config.collect_diagnostics().is_empty()); } #[test] @@ -1380,6 +1381,12 @@ update_environment = ["DISPLAY", "NIRI_SOCKET"] assert!(!session_environment_name_allowed("HERDR_SOCKET_PATH")); assert!(!session_environment_name_allowed("herdr_custom")); assert!(!session_environment_name_allowed("CODEX_THREAD_ID")); + + let config: Config = toml::from_str( + "[session]\nupdate_environment = [\"WAYLAND_DISPLAY\", \"TERM\", \"HERDR_FOO\"]\n", + ) + .unwrap(); + assert_eq!(config.collect_diagnostics().len(), 2); } #[test] diff --git a/src/server/handoff.rs b/src/server/handoff.rs index 132d03b31f..7ea99d4d57 100644 --- a/src/server/handoff.rs +++ b/src/server/handoff.rs @@ -501,14 +501,18 @@ mod tests { None, None, Some("deploying".to_string()), - vec![("WAYLAND_DISPLAY".to_string(), Some("wayland-1".to_string()))], + vec![ + ("WAYLAND_DISPLAY".to_string(), Some("wayland-1".to_string())), + ("SSH_AUTH_SOCK".to_string(), None), + ], ); + let restored: HandoffManifest = serde_json::from_value( + serde_json::to_value(&manifest).expect("manifest should serialize"), + ) + .expect("manifest should deserialize"); - assert_eq!(manifest.api_window_title.as_deref(), Some("deploying")); - assert_eq!( - manifest.session_environment, - vec![("WAYLAND_DISPLAY".to_string(), Some("wayland-1".to_string()))] - ); + assert_eq!(restored.api_window_title.as_deref(), Some("deploying")); + assert_eq!(restored.session_environment, manifest.session_environment); } #[test] diff --git a/src/server/headless.rs b/src/server/headless.rs index 39309fdf98..bf1e64eb92 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -6122,9 +6122,13 @@ mod tests { keybindings: None, direct_attach_requested: false, direct_graphics: true, - environment_update: None, + environment_update: Some(vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()),)]), writer: writer_a, })); + assert_eq!( + server.app.session_environment, + vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))] + ); assert!(server.clients[&1].direct_graphics); assert!(server.clients[&1].pixel_mouse); assert!(server.direct_graphics_available());