Files
galaxy/app/src/terminal/model/session.rs
T

1922 lines
72 KiB
Rust

pub mod active_session;
pub mod command_executor;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fmt::{Debug, Display, Formatter};
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use async_channel::Sender;
#[cfg(feature = "local_tty")]
use command_executor::remote_server_executor::RemoteServerCommandExecutor;
pub use command_executor::*;
use futures::future::{BoxFuture, Shared};
use futures::FutureExt;
use galaxy_completer::completer::{
CommandExitStatus, CommandOutput, PathSeparators, TopLevelCommandCaseSensitivity,
};
use galaxy_util::path::{
convert_msys2_to_windows_native_path, convert_wsl_to_windows_host_path, msys2_exe_to_root,
ShellFamily,
};
use galaxyui::platform::OperatingSystem;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use instant::Instant;
use once_cell::sync::OnceCell;
use parking_lot::{Mutex, RwLock};
use smol_str::SmolStr;
use typed_path::{TypedPath, TypedPathBuf, WindowsPath};
use version_compare::Version;
use super::ansi::{BootstrappedValue, InitShellValue, SSHValue};
use super::terminal_model::{HistoryEntry, SubshellInitializationInfo};
#[cfg(feature = "local_tty")]
use crate::features::FeatureFlag;
#[cfg(feature = "local_tty")]
use crate::remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use crate::server::telemetry::{BootstrappingInfo, TelemetryEvent};
use crate::terminal::event::{ExecutedExecutorCommandEvent, RemoteServerSetupState};
use crate::terminal::shell::{Shell, ShellType};
use crate::terminal::wormhole::SubshellSource;
use crate::terminal::{History, ShellHost, ShellLaunchData};
#[derive(thiserror::Error, Debug)]
pub enum ReadHistoryContentsError {
#[cfg(windows)]
#[error("Couldn't get path to history file")]
HistoryFilePathError,
#[cfg(windows)]
#[error("Error running PowerShell commands to read history file: {0}")]
PowerShellError(anyhow::Error),
#[cfg(windows)]
#[error("Error running PowerShell commands and reading from filesystem to read history file. PowerShell error: {powershell_error}, filesystem error: {async_fs_error}")]
PowerShellAndAsyncFsError {
powershell_error: anyhow::Error,
async_fs_error: std::io::Error,
},
#[error("Error reading history file from filesystem: {0}")]
AsyncFsError(std::io::Error),
}
// SessionId is defined in galaxy_core and re-exported here for backward compatibility.
pub use galaxy_core::SessionId;
/// Information about the sessions within a given terminal pane/top-level
/// shell.
///
/// This stores multiple sessions as each bootstrapped subshell is a separate
/// session (whether it's a true subshell or an SSH session).
#[derive(Debug)]
pub struct Sessions {
/// The start time for pending sessions, keyed by the session's
/// unique ID.
pending_session_start_times: HashMap<SessionId, Instant>,
/// The set of known sessions, keyed by the session's unique ID.
sessions: HashMap<SessionId, Arc<Session>>,
/// The sending side of a channel used by in-band command executors.
executor_command_tx: Sender<ExecutorCommandEvent>,
/// The sending side of channels used to distribute the results of
/// in-band command execution, keyed by session ID.
in_band_command_output_tx_map: HashMap<SessionId, Sender<ExecutedExecutorCommandEvent>>,
/// An executor to use for all spawned sessions.
///
/// This is only intended to be used in tests, which may want to use
/// various mock executor types in order to test and assert on behaviors.
executor_for_all_sessions: Option<Arc<dyn CommandExecutor>>,
/// Select environment variables and their values.
env_vars: HashMap<SessionId, HashMap<String, String>>,
/// Tracks the remote server setup state for SSH sessions that have the
/// `SshRemoteServer` feature flag enabled. Keyed by the pending session ID.
remote_server_setup_states: HashMap<SessionId, RemoteServerSetupState>,
}
#[derive(Clone, Debug)]
pub struct SessionBootstrappedEvent {
pub session_id: SessionId,
pub spawning_command: String,
pub shell: Shell,
pub subshell_info: Option<SubshellInitializationInfo>,
pub session_type: BootstrapSessionType,
}
/// Set of events produced the [`Sessions`] model.
#[derive(Clone, Debug)]
pub enum SessionsEvent {
/// The session was initialized. This does not indicate that the session has bootstrapped, but
/// only that we're aware of the beginning of a session that we will attempt to bootstrap.
SessionInitialized { session_id: SessionId },
/// A new session was successfully bootstrapped.
SessionBootstrapped(Box<SessionBootstrappedEvent>),
/// The environment variables were updated.
EnvironmentVariablesUpdated { session_id: SessionId },
}
impl Entity for Sessions {
type Event = SessionsEvent;
}
impl Sessions {
pub fn new(
executor_command_tx: Sender<ExecutorCommandEvent>,
ctx: &mut ModelContext<Self>,
) -> Self {
// Track the connected host_id on the `Session` type so downstream
// code can distinguish hosts. The `RemoteServerCommandExecutor`
// client itself is baked in at session construction time
// (see `new_command_executor_for_local_tty_session`) so we no
// longer need to wire it here on connect/disconnect.
#[cfg(feature = "local_tty")]
if FeatureFlag::SshRemoteServer.is_enabled() {
let mgr = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&mgr, |sessions, _, event, ctx| match event {
RemoteServerManagerEvent::SessionConnected {
session_id: sid,
host_id,
} => {
if let Some(session) = sessions.sessions.get(sid) {
session.set_remote_host_id(Some(host_id.clone()));
}
}
RemoteServerManagerEvent::SessionDisconnected {
session_id: sid, ..
} => {
if let Some(session) = sessions.sessions.get(sid) {
session.set_remote_host_id(None);
}
}
RemoteServerManagerEvent::SetupStateChanged { session_id, state } => {
sessions.set_remote_server_setup_state(*session_id, state.clone());
ctx.notify();
}
RemoteServerManagerEvent::BufferUpdated { .. }
| RemoteServerManagerEvent::BufferConflictDetected { .. } => {
// Handled directly by GlobalBufferModel's subscription.
}
RemoteServerManagerEvent::SessionConnecting { .. }
| RemoteServerManagerEvent::SessionDeregistered { .. }
| RemoteServerManagerEvent::SessionConnectionFailed { .. }
| RemoteServerManagerEvent::HostConnected { .. }
| RemoteServerManagerEvent::HostDisconnected { .. }
| RemoteServerManagerEvent::RemoteAgentContextSnapshot { .. }
| RemoteServerManagerEvent::NavigatedToDirectory { .. }
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
| RemoteServerManagerEvent::RepoMetadataUpdated { .. }
| RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. }
| RemoteServerManagerEvent::CodebaseIndexStatusesSnapshot { .. }
| RemoteServerManagerEvent::CodebaseIndexStatusUpdated { .. }
| RemoteServerManagerEvent::CodebaseIndexMutationFailed { .. }
| RemoteServerManagerEvent::BinaryCheckComplete { .. }
| RemoteServerManagerEvent::BinaryInstallComplete { .. }
| RemoteServerManagerEvent::ClientRequestFailed { .. }
| RemoteServerManagerEvent::ServerMessageDecodingError { .. }
| RemoteServerManagerEvent::DiffStateSnapshotReceived { .. }
| RemoteServerManagerEvent::DiffStateMetadataUpdateReceived { .. }
| RemoteServerManagerEvent::DiffStateFileDeltaReceived { .. }
| RemoteServerManagerEvent::GetBranchesResponse { .. }
| RemoteServerManagerEvent::CommitChainResponse { .. }
| RemoteServerManagerEvent::GitPushResponse { .. }
| RemoteServerManagerEvent::CreatePrResponse { .. }
| RemoteServerManagerEvent::GenerateCommitMessageResponse { .. }
| RemoteServerManagerEvent::GetCommittedBranchFilesResponse { .. }
| RemoteServerManagerEvent::GitStatusPushReceived { .. }
| RemoteServerManagerEvent::GitHubPrInfoPushReceived { .. }
| RemoteServerManagerEvent::GitHubRepositoryInfoPushReceived { .. } => {}
RemoteServerManagerEvent::SessionReconnected {
session_id: sid,
client,
..
} => {
if let Some(session) = sessions.sessions.get(sid) {
let new_executor =
Arc::new(RemoteServerCommandExecutor::new(*sid, client.clone()));
session.set_command_executor(new_executor);
log::info!("Swapped command executor for session {sid:?} after reconnect");
}
}
});
}
#[cfg(not(feature = "local_tty"))]
let _ = ctx;
Self {
pending_session_start_times: Default::default(),
sessions: Default::default(),
executor_command_tx,
in_band_command_output_tx_map: Default::default(),
executor_for_all_sessions: None,
env_vars: Default::default(),
remote_server_setup_states: Default::default(),
}
}
pub fn is_any_session_remote(&self) -> bool {
self.sessions.values().any(|session| !session.is_local())
}
pub fn is_session_remote(&self, session_id: SessionId) -> bool {
self.sessions
.get(&session_id)
.map(|session| !session.is_local())
.unwrap_or(false)
}
#[cfg(test)]
pub fn new_for_test() -> Self {
let (executor_command_tx, _executor_command_rx) = async_channel::unbounded();
Self {
pending_session_start_times: Default::default(),
sessions: Default::default(),
executor_command_tx,
in_band_command_output_tx_map: Default::default(),
executor_for_all_sessions: None,
env_vars: Default::default(),
remote_server_setup_states: Default::default(),
}
}
#[cfg(test)]
pub fn with_command_executor(mut self, executor: Arc<dyn CommandExecutor>) -> Self {
self.executor_for_all_sessions = Some(executor);
self
}
pub fn set_env_vars_for_session(
&mut self,
session_id: SessionId,
env_vars: HashMap<String, String>,
ctx: &mut ModelContext<Self>,
) {
let old_vars = self.env_vars.insert(session_id, env_vars);
let new_vars = self.env_vars.get(&session_id);
// We compute a list to see which env vars actually changed. If there were no
// actual changes in env var, we do not fire an event
let did_change = match (old_vars, new_vars) {
(None, None) => false,
(None, Some(new_vars)) => !new_vars.is_empty(),
(Some(old_vars), None) => !old_vars.is_empty(),
(Some(old_vars), Some(new_vars)) => old_vars != *new_vars,
};
if did_change {
ctx.emit(SessionsEvent::EnvironmentVariablesUpdated { session_id })
}
}
pub fn get_env_vars_for_session(
&self,
session_id: SessionId,
) -> Option<HashMap<String, String>> {
self.env_vars.get(&session_id).cloned()
}
/// Updates the remote server setup state for the given session.
pub fn set_remote_server_setup_state(
&mut self,
session_id: SessionId,
state: RemoteServerSetupState,
) {
self.remote_server_setup_states.insert(session_id, state);
}
/// Returns the current remote server setup state for the given session, if any.
pub fn remote_server_setup_state(
&self,
session_id: SessionId,
) -> Option<&RemoteServerSetupState> {
self.remote_server_setup_states.get(&session_id)
}
pub fn register_pending_session(
&mut self,
session_info: &SessionInfo,
ctx: &mut ModelContext<Self>,
) {
self.pending_session_start_times
.insert(session_info.session_id, Instant::now());
ctx.emit(SessionsEvent::SessionInitialized {
session_id: session_info.session_id,
})
}
pub fn initialize_bootstrapped_session(
&mut self,
session_info: SessionInfo,
spawning_command: String,
restored_block_commands: Vec<HistoryEntry>,
rcfiles_duration_seconds: Option<f64>,
ctx: &mut ModelContext<Self>,
) {
// Remove the session from the list of pending sessions.
let pending_session_start_time = self
.pending_session_start_times
.remove(&session_info.session_id);
let session_id = session_info.session_id;
let (in_band_command_output_tx, in_band_command_output_rx) = async_channel::unbounded();
let command_executor = if let Some(executor) = &self.executor_for_all_sessions {
// Explicitly drop the receiver to match the other branch, where
// it gets consumed through the function call.
let _ = in_band_command_output_rx;
executor.clone()
} else {
let parent_session_info = session_info
.spawning_session_id
.as_ref()
.and_then(|session_id| self.sessions.get(session_id))
.map(|session| &session.info);
command_executor::new_command_executor_for_session(
&session_info,
&self.executor_command_tx,
in_band_command_output_rx,
parent_session_info,
ctx,
)
};
if !in_band_command_output_tx.is_closed() {
// If the receiving side of the channel was stored somewhere (and
// not just dropped), store the sending side of the channel so we
// can proxy output to it.
self.in_band_command_output_tx_map
.insert(session_info.session_id, in_band_command_output_tx);
}
let session = Session::new(session_info.clone(), command_executor);
log::info!("Shell is bootstrapped with session_id {:?}", session.id());
let session = Arc::new(session);
self.sessions.insert(session.id(), session.clone());
// For wormholed-remote sessions, pick up the current host_id from
// the manager so session.remote_host_id() is populated without
// waiting for the next SessionConnected event. The
// RemoteServerCommandExecutor already has its client baked in, so
// nothing else needs to be wired here.
#[cfg(feature = "local_tty")]
if FeatureFlag::SshRemoteServer.is_enabled()
&& matches!(
session_info.session_type,
BootstrapSessionType::WormholedRemote
)
{
if let Some(host_id) = RemoteServerManager::as_ref(ctx).host_id_for_session(session_id)
{
session.set_remote_host_id(Some(host_id.clone()));
}
}
let bootstrap_duration_seconds =
pending_session_start_time.map(|start| start.elapsed().as_secs_f64());
let warp_attributed_bootstrap_duration_seconds =
match (bootstrap_duration_seconds, rcfiles_duration_seconds) {
(Some(total), Some(rcfiles)) => Some(total - rcfiles),
_ => None,
};
let was_triggered_by_rc_file = session
.subshell_info()
.clone()
.map(|info| info.was_triggered_by_rc_file_snippet)
.unwrap_or(false);
crate::send_telemetry_from_ctx!(
TelemetryEvent::BootstrappingSucceeded(BootstrappingInfo {
shell: session.shell().shell_type().name(),
shell_version: session.shell().version().clone(),
is_ssh: session.is_ssh_wrapper_session(),
was_triggered_by_rc_file,
is_subshell: session.subshell_info().is_some(),
is_wsl: session.is_wsl(),
bootstrap_duration_seconds,
rcfiles_duration_seconds,
warp_attributed_bootstrap_duration_seconds,
is_msys2: session.is_msys2(),
terminal_session_id: Some(session.id()),
}),
ctx
);
History::handle(ctx).update(ctx, |history, ctx| {
let session_id = session.id();
let shell_host = ShellHost::from_session(session.as_ref());
history.init_session(session, ctx);
let this_host_commands: Vec<_> = restored_block_commands
.iter()
.filter(|item| {
item.shell_host
.as_ref()
.is_none_or(|host| *host == shell_host)
})
.cloned()
.collect();
// Append the restored block commands at the end of history.
history.append_restored_commands(session_id, this_host_commands);
});
ctx.emit(SessionsEvent::SessionBootstrapped(Box::new(
SessionBootstrappedEvent {
session_id,
spawning_command,
shell: session_info.shell,
subshell_info: session_info.subshell_info,
session_type: session_info.session_type,
},
)))
}
pub fn get(&self, session_id: SessionId) -> Option<Arc<Session>> {
self.sessions.get(&session_id).map(Clone::clone)
}
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
/// Returns whether we're aware of the existence of any sessions, whether
/// they are pending or fully bootstrapped.
pub fn has_pending_or_bootstrapped_session(&self) -> bool {
!self.pending_session_start_times.is_empty() || !self.sessions.is_empty()
}
/// Returns whether the given `session_id` is tracked by this [`Sessions`]
/// model, either as a pending session (registered via [`Self::register_pending_session`])
/// or a fully bootstrapped one.
pub fn tracks_session(&self, session_id: SessionId) -> bool {
self.sessions.contains_key(&session_id)
|| self.pending_session_start_times.contains_key(&session_id)
}
/// Returns a map of the spawning commands for all subshell sessions, keyed the session's `SessionId`.
pub fn spawning_command_for_subshell_sessions(&self) -> HashMap<SessionId, SubshellSource> {
self.sessions
.iter()
.filter_map(|(id, session)| {
session.subshell_info().as_ref().map(|info| {
(
*id,
if let Some(env_var_collection_name) = &info.env_var_collection_name {
SubshellSource::EnvVarCollection(env_var_collection_name.clone())
} else {
SubshellSource::Command(info.spawning_command.clone())
},
)
})
})
.collect()
}
/// Handles an [`ExecutedExecutorCommandEvent`] by forwarding the event to
/// the session's [`InBandCommandExecutor`].
pub fn handle_executed_command_event(
&mut self,
session_id: SessionId,
event: ExecutedExecutorCommandEvent,
) {
if let Some(in_band_command_output_tx) = self.in_band_command_output_tx_map.get(&session_id)
{
if let Err(e) = in_band_command_output_tx.try_send(event) {
log::error!(
"Failed to send ExecutedExecutorCommandEvent to InBandCommandExecutor: {e:?}"
);
}
}
}
/// Registers a session in the map for the purpose of testing.
///
/// Prefer using [`Self::initialize_bootstrapped_session`] to properly register
/// a session (e.g. emit the appropriate events).
#[cfg(test)]
pub fn register_session_for_test(&mut self, session: SessionInfo) {
use command_executor::testing::TestCommandExecutor;
self.sessions.insert(
session.session_id,
Arc::new(Session::new(
session,
Arc::new(TestCommandExecutor::default()),
)),
);
}
}
impl From<SessionType> for command_corrections::SessionType {
fn from(session_type: SessionType) -> Self {
match session_type {
SessionType::WormholedRemote { .. } => command_corrections::SessionType::Remote,
SessionType::Local => command_corrections::SessionType::Local,
}
}
}
impl From<&SessionType> for command_corrections::SessionType {
fn from(session_type: &SessionType) -> Self {
match session_type {
SessionType::WormholedRemote { .. } => command_corrections::SessionType::Remote,
SessionType::Local => command_corrections::SessionType::Local,
}
}
}
/// Whether a session was established by Galaxy's in-band SSH wrapper — the shell function our
/// bootstrap injects that intercepts `ssh`, sets up a ControlMaster connection, and bootstraps
/// the remote shell. This applies to all SSH wormholing today: the remote-server SSH
/// extension also runs on top of a wrapper session (reusing the ControlMaster socket for its
/// proxy and for the `RemoteCommandExecutor` fallback).
///
/// `No` covers local sessions, subshells, and remote sessions wormholed *without* the wrapper
/// (e.g. via the auto-wormhole RC snippet inside an unwrapped `ssh` session), which carry no
/// ControlMaster socket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IsSSHWrapperSession {
/// The session was established by the SSH wrapper; `socket_path` is the ControlMaster
/// socket for the underlying connection.
Yes {
socket_path: PathBuf,
/// `true` when `socket_path` points at a ControlMaster the user
/// already had running (the SSH wrapper attached to it instead of
/// creating a Galaxy-owned one). Galaxy must not tear down such a
/// master on session exit.
external_control_master: bool,
},
No,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostInfo {
// TODO(CORE-2219): This should be an enum instead of a string
pub os_category: Option<String>,
pub linux_distribution: Option<String>,
}
impl HostInfo {
// TODO(CORE-2219): Once we have a struct instead of a string type,
// we should instead implement this as either
// From<StructName> for command_corrections::PlatformType
// TryFrom<StructName for command_corrections::PlatformType
pub fn platform_type(&self) -> command_corrections::PlatformType {
use command_corrections::PlatformType::*;
let Some(category) = &self.os_category else {
return Posix;
};
if category == "Windows" {
Windows
} else {
Posix
}
}
}
/// Session information sent from the shell to the Rust app after bootstrap.
///
/// This is an intermediate abstraction between the [`BootstrappedValue`] read from the pty at
/// session bootstrap and the [`Session`] model object managed by the Rust app. At session bootstrap
/// time, the `TerminalModel` constructs this from the session `BootstrappedValue` and emits it as
/// part of the `BootstrappedEvent` payload.
#[derive(Debug, Clone)]
pub struct SessionInfo {
pub session_id: SessionId,
pub shell: Shell,
pub launch_data: Option<ShellLaunchData>,
pub histfile: Option<String>,
pub user: String,
pub hostname: String,
pub subshell_info: Option<SubshellInitializationInfo>,
pub path: Option<String>,
pub environment_variable_names: HashSet<SmolStr>,
pub aliases: HashMap<SmolStr, String>,
pub abbreviations: HashMap<SmolStr, String>,
// A Vec is sufficient here because function_names are guaranteed to be unique.
pub function_names: HashSet<SmolStr>,
pub builtins: HashSet<SmolStr>,
pub keywords: Vec<SmolStr>,
pub is_ssh_wrapper_session: IsSSHWrapperSession,
pub home_dir: Option<String>,
pub cdpath: Option<String>,
pub editor: Option<String>,
pub session_type: BootstrapSessionType,
pub host_info: HostInfo,
pub wsl_name: Option<String>,
/// If this is a subshell or remote session, e.g. ssh, store the parent session ID here.
pub spawning_session_id: Option<SessionId>,
}
impl SessionInfo {
/// Returns a partially populated `SessionInfo` constructed from data contained in the given
/// arguments. Most notably, this is created after the InitShell DCS hook is received, which
/// contains the SessionId.
///
/// This should be called to cache data from the InitShell value after the InitShell DCS for a
/// newly spawned session is received.
pub fn create_pending(
shell_type: ShellType,
init_shell_value: InitShellValue,
subshell_info: Option<SubshellInitializationInfo>,
launch_data: Option<ShellLaunchData>,
ssh_wrapper_session: Option<SSHValue>,
active_block_session_id: Option<SessionId>,
) -> Self {
let is_ssh_wrapper_session = match ssh_wrapper_session {
Some(ssh_value) => IsSSHWrapperSession::Yes {
socket_path: ssh_value.socket_path,
external_control_master: ssh_value.external_control_master,
},
None => IsSSHWrapperSession::No,
};
if launch_data.is_none() && is_ssh_wrapper_session == IsSSHWrapperSession::No {
log::warn!("pending_local_shell_path was None for a local session");
}
// Compare the hostname of the session bootstrap payload with the hostname of the machine
// to determine if this is a local or remote session.
let session_type = Self::determine_session_type(
&init_shell_value,
matches!(&is_ssh_wrapper_session, IsSSHWrapperSession::Yes { .. }),
);
let spawning_session_id = if matches!(session_type, BootstrapSessionType::WormholedRemote)
|| subshell_info.is_some()
{
active_block_session_id
} else {
None
};
SessionInfo {
session_id: init_shell_value.session_id,
shell: Shell::new(shell_type, None, None, Default::default(), None),
launch_data,
user: init_shell_value.user,
hostname: init_shell_value.hostname,
session_type,
subshell_info,
is_ssh_wrapper_session,
environment_variable_names: Default::default(),
path: None,
home_dir: None,
cdpath: None,
editor: None,
histfile: None,
aliases: Default::default(),
abbreviations: Default::default(),
function_names: Default::default(),
builtins: Default::default(),
keywords: Default::default(),
host_info: Default::default(),
wsl_name: init_shell_value.wsl_name,
spawning_session_id,
}
}
#[cfg(not(feature = "remote_tty"))]
fn determine_session_type(
init_shell_value: &InitShellValue,
is_ssh_session: bool,
) -> BootstrapSessionType {
match get_local_hostname() {
Ok(local_hostname) => {
// Ensures subshells are treated as local
if local_hostname == init_shell_value.hostname &&
// Ensures `ssh localhost` is treated as remote
!is_ssh_session
{
BootstrapSessionType::Local
} else {
BootstrapSessionType::WormholedRemote
}
}
Err(e) => {
crate::report_error!(e);
BootstrapSessionType::Local
}
}
}
#[cfg(feature = "remote_tty")]
fn determine_session_type(
_init_shell_value: &InitShellValue,
_is_ssh_session: bool,
) -> BootstrapSessionType {
// When the `remote_tty` feature is enabled--the session is always considered remote.
BootstrapSessionType::WormholedRemote
}
/// Returns a fully populated [`SessionInfo`] containing data derived from the given
/// `bootstrapped_value`. SessionId, user, and hostname are carried over from `self` since
/// these are populated in [`Self::create_pending()`].
///
/// This should be called on the pending `SessionInfo` after the session is bootstrapped and
/// used to create the canonical `Session` object for the newly bootstrapped session.
pub fn merge_from_bootstrapped_value(mut self, bootstrapped_value: BootstrappedValue) -> Self {
// Determine the value from the bootstrap message, falling back to the cached shell type
// (from the `InitShell` payload) if unable to parse.
let shell_type = match ShellType::from_name(bootstrapped_value.shell.as_str()) {
Some(value) => {
if value != self.shell.shell_type() {
log::error!("Received ShellType {:?} in BootstrappedValue that conflicts with pending ShellType {:?}", value, self.shell.shell_type());
}
value
}
None => self.shell.shell_type(),
};
let home_dir = bootstrapped_value.home_dir;
let aliases = bootstrapped_value
.aliases
.map(|alias_output| shell_type.aliases(alias_output.as_str()));
let abbreviations = bootstrapped_value
.abbreviations
.map(|abbr_output| shell_type.abbreviations(abbr_output.as_str()));
let function_names = bootstrapped_value
.function_names
.map(|function_names_output| function_names_output.lines().map(Into::into).collect());
let builtins = bootstrapped_value
.builtins
.map(|builtins_output| builtins_output.lines().map(Into::into).collect());
let keywords = bootstrapped_value
.keywords
.map(|keywords_output| keywords_output.lines().map(Into::into).collect());
let env_var_names = bootstrapped_value.env_var_names.map(|names| {
// In zsh the output of `echo ${(k)parameters[(R)*export*]}` is a single line separated
// by spaces, whereas `compgen -e` in Bash and `set --names` in Fish put each env var on
// a separate line.
let split = match &self.shell.shell_type() {
ShellType::Zsh | ShellType::PowerShell => names.split(' '),
ShellType::Bash | ShellType::Fish => names.split('\n'),
};
split.map(Into::into).collect::<HashSet<_>>()
});
let options = if bootstrapped_value
.vi_mode_enabled
.is_some_and(|vi_mode| vi_mode.eq("1"))
{
let mut opts = bootstrapped_value.shell_options.unwrap_or_default();
opts.insert("vi_mode".into());
Some(opts)
} else {
bootstrapped_value.shell_options
};
SessionInfo {
session_id: self.session_id,
shell: Shell::new(
shell_type,
bootstrapped_value.shell_version,
options,
bootstrapped_value.shell_plugins.unwrap_or_default(),
bootstrapped_value.shell_path,
),
launch_data: self.launch_data.take(),
histfile: bootstrapped_value.histfile,
user: self.user,
hostname: self.hostname,
session_type: self.session_type,
path: bootstrapped_value.path,
environment_variable_names: env_var_names.unwrap_or_default(),
aliases: aliases.unwrap_or_default(),
abbreviations: abbreviations.unwrap_or_default(),
function_names: function_names.unwrap_or_default(),
builtins: builtins.unwrap_or_default(),
keywords: keywords.unwrap_or_default(),
home_dir,
cdpath: bootstrapped_value.cdpath,
editor: bootstrapped_value.editor,
is_ssh_wrapper_session: self.is_ssh_wrapper_session,
subshell_info: self.subshell_info.take(),
host_info: HostInfo {
os_category: bootstrapped_value.os_category,
linux_distribution: bootstrapped_value.linux_distribution,
},
wsl_name: bootstrapped_value.wsl_name,
spawning_session_id: self.spawning_session_id,
}
}
/// Returns the name of the WSL distribution, or `None` if this session is not a WSL session.
fn wsl_name(&self) -> Option<&str> {
self.wsl_name
.as_deref()
.or(self
.launch_data
.as_ref()
.and_then(|launch_data| match launch_data {
ShellLaunchData::WSL { distro } => Some(distro.as_str()),
_ => None,
}))
}
/// If the path is for a session inside some emulation layer, like a VM for WSL, convert a
/// paths from inside the session into something the native host can use. Otherwise, leave the
/// path as-is.
pub fn maybe_convert_to_native_path(&self, path: &TypedPath) -> anyhow::Result<PathBuf> {
if let Some(distro) = self.wsl_name() {
return Ok(convert_wsl_to_windows_host_path(path, distro)?);
}
if let Some(ShellLaunchData::MSYS2 {
executable_path, ..
}) = &self.launch_data
{
return Ok(convert_msys2_to_windows_native_path(
path,
&msys2_exe_to_root(WindowsPath::new(
executable_path.as_os_str().as_encoded_bytes(),
)),
)?);
}
PathBuf::try_from(path.to_path_buf())
.map_err(|path| anyhow::anyhow!("Unable to convert path: {path:?}"))
}
}
/// The session type determined at bootstrap time.
///
/// Unlike [`SessionType`], this does not carry a `host_id` because that
/// information is not available until the remote-server handshake completes,
/// which happens *after* the session is bootstrapped.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BootstrapSessionType {
/// The session host is the same host where Galaxy is running.
Local,
/// The session host is a different host from where Galaxy is running.
WormholedRemote,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SessionType {
/// The session host is the same host where Galaxy is running.
Local,
/// The session host is a different host from where Galaxy is running.
/// Note that we only know this for sure when we Wormhole a block.
///
/// `host_id` is `Some` when the remote server feature flag is enabled and
/// `RemoteServerManager` has completed the connection handshake. It is
/// `None` when the feature flag is off or the connection hasn't been
/// established yet.
WormholedRemote {
host_id: Option<galaxy_core::HostId>,
},
}
impl From<BootstrapSessionType> for SessionType {
fn from(bst: BootstrapSessionType) -> Self {
match bst {
BootstrapSessionType::Local => SessionType::Local,
BootstrapSessionType::WormholedRemote => SessionType::WormholedRemote { host_id: None },
}
}
}
/// Represents session state and context, mostly populated and constructed at session bootstrap
/// time.
///
/// This object exposes methods for querying the session context (e.g. history, aliases, executable
/// names) as well as command execution, in which an arbitrary shell command may be executed in a
/// context similar to that of the session (e.g. with the same environment variable definitions,
/// $PATH, working directory, etc).
#[derive(Debug)]
pub struct Session {
info: SessionInfo,
external_commands: OnceCell<HashSet<SmolStr>>,
/// Function names collected asynchronously after bootstrap via an in-band command.
additional_function_names: OnceCell<HashSet<SmolStr>>,
/// builtin/cmdlet names collected asynchronously after bootstrap via an in-band command.
additional_builtin_names: OnceCell<HashSet<SmolStr>>,
/// The command executor for this session. Behind a `RwLock` so it can be
/// swapped after a remote server reconnect (via `set_command_executor`).
command_executor: RwLock<Arc<dyn CommandExecutor>>,
load_external_commands_future: OnceCell<Shared<BoxFuture<'static, ()>>>,
load_all_function_names_future: OnceCell<Shared<BoxFuture<'static, ()>>>,
load_all_builtins_future: OnceCell<Shared<BoxFuture<'static, ()>>>,
command_case_sensitivity: TopLevelCommandCaseSensitivity,
/// The authoritative session type, initially derived from the
/// [`BootstrapSessionType`] in `SessionInfo` and updated by [`Sessions`]
/// when `RemoteServerManager` reports a connected session (to fill in the
/// `host_id`). Interior mutability allows updating through `Arc<Session>`.
session_type: Mutex<SessionType>,
}
impl Session {
pub fn new(session_info: SessionInfo, command_executor: Arc<dyn CommandExecutor>) -> Self {
if let Some(version) = &session_info.shell.version() {
log::info!("Parsed shell version string: {:?}", Version::from(version));
}
let command_case_sensitivity = session_info
.host_info
.os_category
.as_deref()
.map(TopLevelCommandCaseSensitivity::from_os_category)
.unwrap_or_else(|| OperatingSystem::get().into());
let session_type = SessionType::from(session_info.session_type.clone());
Self {
info: session_info,
external_commands: OnceCell::new(),
additional_function_names: OnceCell::new(),
additional_builtin_names: OnceCell::new(),
command_executor: RwLock::new(command_executor),
load_external_commands_future: Default::default(),
load_all_function_names_future: Default::default(),
load_all_builtins_future: Default::default(),
command_case_sensitivity,
session_type: Mutex::new(session_type),
}
}
pub fn id(&self) -> SessionId {
self.info.session_id
}
pub fn user(&self) -> &str {
self.info.user.as_str()
}
pub fn hostname(&self) -> &str {
self.info.hostname.as_str()
}
pub fn session_type(&self) -> SessionType {
self.session_type.lock().clone()
}
/// Updates the `host_id` on a `WormholedRemote` session type after the
/// remote server handshake completes (or clears it on disconnect).
pub fn set_remote_host_id(&self, host_id: Option<galaxy_core::HostId>) {
let mut st = self.session_type.lock();
if let SessionType::WormholedRemote { host_id: ref mut h } = *st {
*h = host_id;
}
}
pub fn shell_family(&self) -> ShellFamily {
self.shell().shell_type().into()
}
pub fn launch_data(&self) -> Option<&ShellLaunchData> {
self.info.launch_data.as_ref()
}
pub fn maybe_convert_to_native_path(&self, path: &TypedPath) -> anyhow::Result<PathBuf> {
self.info.maybe_convert_to_native_path(path)
}
pub fn path_separators(&self) -> PathSeparators {
match self.shell().shell_type() {
ShellType::Zsh | ShellType::Bash | ShellType::Fish => PathSeparators::for_unix(),
ShellType::PowerShell => PathSeparators::for_os(),
}
}
pub fn home_dir(&self) -> Option<&str> {
if cfg!(test) {
return galaxy_util::path::TEST_SESSION_HOME_DIR.as_deref();
}
self.info.home_dir.as_deref()
}
pub fn editor(&self) -> Option<&str> {
self.info.editor.as_deref()
}
pub fn cdpath(&self) -> Option<&str> {
self.info.cdpath.as_deref()
}
pub fn host_info(&self) -> HostInfo {
self.info.host_info.clone()
}
/// Returns whether this session was established by Galaxy's in-band SSH wrapper (see
/// [`IsSSHWrapperSession`]). Note this stays `false` for remote sessions wormholed via
/// the auto-wormhole RC snippet inside an unwrapped `ssh` session.
pub fn is_ssh_wrapper_session(&self) -> bool {
matches!(
self.info.is_ssh_wrapper_session,
IsSSHWrapperSession::Yes { .. }
)
}
pub fn is_subshell_or_ssh(&self) -> bool {
matches!(self.session_type(), SessionType::WormholedRemote { .. })
|| self.is_ssh_wrapper_session()
|| self.subshell_info().is_some()
}
pub fn is_wsl(&self) -> bool {
self.info.wsl_name().is_some()
}
pub fn wsl_distro_name(&self) -> Option<&str> {
self.info.wsl_name()
}
pub fn is_msys2(&self) -> bool {
matches!(self.launch_data(), Some(ShellLaunchData::MSYS2 { .. }))
}
/// Returns the function that converts a Windows-native path into this session's native
/// representation, or `None` when no conversion is appropriate.
pub fn windows_path_converter(&self) -> Option<fn(&str) -> String> {
if self.is_wsl() {
Some(galaxy_util::path::convert_windows_path_to_wsl)
} else if self.is_msys2() {
Some(galaxy_util::path::convert_windows_path_to_msys2)
} else {
None
}
}
pub fn alias_names(&self) -> impl Iterator<Item = &str> {
self.info.aliases.keys().map(Deref::deref)
}
pub fn builtin_names(&self) -> impl Iterator<Item = &str> {
self.info
.builtins
.iter()
.chain(self.additional_builtin_names.get().into_iter().flatten())
.map(Deref::deref)
}
pub fn function_names(&self) -> impl Iterator<Item = &str> {
self.info
.function_names
.iter()
.chain(self.additional_function_names.get().into_iter().flatten())
.map(Deref::deref)
}
pub fn executable_names(&self) -> impl Iterator<Item = &str> {
self.external_commands
.get()
.into_iter()
.flatten()
.map(Deref::deref)
}
pub fn aliases(&self) -> &HashMap<SmolStr, String> {
&self.info.aliases
}
pub fn alias_value(&self, name: &str) -> Option<&str> {
self.info.aliases.get(name).map(Deref::deref)
}
pub fn abbreviations(&self) -> &HashMap<SmolStr, String> {
&self.info.abbreviations
}
pub fn abbreviation_value(&self, name: &str) -> Option<&str> {
self.info.abbreviations.get(name).map(Deref::deref)
}
pub fn functions(&self) -> &HashSet<SmolStr> {
&self.info.function_names
}
pub fn builtins(&self) -> &HashSet<SmolStr> {
&self.info.builtins
}
pub fn subshell_info(&self) -> &Option<SubshellInitializationInfo> {
&self.info.subshell_info
}
/// Replaces the command executor for this session. Used after a remote
/// server reconnect to swap in a new `RemoteServerCommandExecutor`
/// backed by the reconnected client.
pub fn set_command_executor(&self, executor: Arc<dyn CommandExecutor>) {
*self.command_executor.write() = executor;
}
/// Returns true if the session is employing in-band command execution to run generators.
pub fn is_using_in_band_command_execution(&self) -> bool {
self.command_executor
.read()
.as_ref()
.as_any()
.downcast_ref::<InBandCommandExecutor>()
.is_some()
}
/// Returns `true` if already attempted to load external commands for the `Session`.
pub fn has_attempted_to_load_external_commands(&self) -> bool {
self.load_external_commands_future.get().is_some()
}
/// Returns `true` if external commands have finished loading for this `Session`.
pub fn has_loaded_external_commands(&self) -> bool {
self.external_commands.get().is_some()
}
/// Asynchronously collects all function names via an in-band shell command.
pub async fn load_all_function_names(&self) {
let Some(command) = self
.info
.shell
.shell_type()
.shell_command_to_get_all_functions()
else {
return;
};
self.load_deferred_name_set(
command,
&self.info.function_names,
&self.additional_function_names,
&self.load_all_function_names_future,
"function",
)
.await;
}
/// Asynchronously collects all builtin (cmdlet) names via an in-band shell command.
pub async fn load_all_builtins(&self) {
let Some(command) = self
.info
.shell
.shell_type()
.shell_command_to_get_all_builtins()
else {
return;
};
self.load_deferred_name_set(
command,
&self.info.builtins,
&self.additional_builtin_names,
&self.load_all_builtins_future,
"builtin",
)
.await;
}
/// Shared helper for [`Self::load_all_function_names`] and [`Self::load_all_builtins`].
async fn load_deferred_name_set(
&self,
command: &'static str,
existing: &HashSet<SmolStr>,
storage: &OnceCell<HashSet<SmolStr>>,
future_cell: &OnceCell<Shared<BoxFuture<'static, ()>>>,
label: &'static str,
) {
let (load_future, receiver) = (async {
let result = self
.execute_command(command, None, None, ExecuteCommandOptions::default())
.await;
let new_names: HashSet<SmolStr> = match result {
Ok(output) if output.status == CommandExitStatus::Success => {
match output.to_string() {
Ok(output_string) => output_string
.lines()
.filter(|name| !name.is_empty() && !existing.contains(*name))
.map(Into::into)
.collect(),
Err(e) => {
log::warn!("Failed to decode {label} names output: {e:#}");
HashSet::new()
}
}
}
Ok(_) => {
log::warn!("In-band command for {label} names returned non-success status");
HashSet::new()
}
Err(e) => {
log::warn!("Failed to load {label} names: {e:#}");
HashSet::new()
}
};
if storage.set(new_names).is_err() {
log::warn!("Additional {label} names were already set for this session.");
}
})
.remote_handle();
match future_cell.try_insert(receiver.boxed().shared()) {
Ok(_) => load_future.await,
Err((existing_receiver, _)) => existing_receiver.clone().await,
};
}
/// Asynchronously loads the external commands.
///
/// If this is called while a previous call to `load_external_commands` is
/// still running, this will not resolve until the previous call does. This
/// means it is safe to call `load_external_commands` multiple times, and
/// that when a call resolves, there is a guarantee that external commands
/// have been loaded.
///
/// Note that we load executables post-bootstrap because we don't need an interactive, login shell
/// to get them (unlike aliases, functions and env-vars). All we need is the user's $PATH var,
/// which we have access to at this point.
pub async fn load_external_commands(&self) {
let (load_future, receiver) = (async {
let shell = self.info.shell.clone();
let shell_command_to_get_executables =
shell.shell_type().shell_command_to_get_executables();
let env_vars = self
.info
.path
.as_deref()
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
let result = self
.execute_command(
shell_command_to_get_executables,
None,
env_vars,
ExecuteCommandOptions::default(),
)
.await;
let is_msys2 =
self.info.launch_data.as_ref().is_some_and(|launch_data| {
matches!(launch_data, ShellLaunchData::MSYS2 { .. })
});
// We gather the external Windows-specific commands by using PowerShell because
// Git Bash's `compgen` is slow at gathering these. The Git Bash-specific commands
// like ls.exe are retrieved above.
let mut new_commands = if is_msys2 {
let env_vars = self
.info
.path
.as_deref()
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
let executor = self.command_executor.read().clone();
let windows_results = executor
.execute_command(
ShellType::PowerShell.shell_command_to_get_executables(),
&Shell::new(ShellType::PowerShell, None, None, Default::default(), None),
None,
env_vars,
ExecuteCommandOptions::default(),
)
.await;
HashSet::from_iter(ShellType::PowerShell.executables_from_shell_command_output(
windows_results,
false, /* is_msys2 */
))
} else {
HashSet::new()
};
new_commands.extend(
shell
.shell_type()
.executables_from_shell_command_output(result, is_msys2),
);
if self.external_commands.set(new_commands).is_err() {
log::warn!("External commands should only be loaded once per session.");
}
})
.remote_handle();
match self
.load_external_commands_future
.try_insert(receiver.boxed().shared())
{
Ok(_) => load_future.await,
Err((existing_receiver, _)) => existing_receiver.clone().await,
};
}
/// All of the top-level commands within this session. This includes executables on the
/// user's PATH and aliases.
pub fn top_level_commands(&self) -> impl Iterator<Item = &str> {
self.external_commands
.get()
.into_iter()
.flatten()
.chain(&self.info.function_names)
.chain(self.additional_function_names.get().into_iter().flatten())
.chain(self.info.aliases.keys())
.chain(self.info.abbreviations.keys())
.chain(&self.info.builtins)
.chain(self.additional_builtin_names.get().into_iter().flatten())
.chain(&self.info.keywords)
.map(Deref::deref)
}
pub fn path(&self) -> &Option<String> {
&self.info.path
}
pub fn histfile(&self) -> &Option<String> {
&self.info.histfile
}
pub fn shell(&self) -> &Shell {
&self.info.shell
}
pub fn is_local(&self) -> bool {
self.session_type() == SessionType::Local
}
async fn read_history_for_local_session(&self, is_kaspersky_running: bool) -> Vec<String> {
let histfile = &self.info.histfile;
let shell_type = &self.info.shell.shell_type();
let history_files = histfile.as_ref().map_or_else(
|| shell_type.history_files(),
|histfile| vec![histfile.to_string()],
);
for history_file in history_files {
let typed_path = TypedPath::from(history_file.as_str());
let Ok(history_file) = self.maybe_convert_to_native_path(&typed_path) else {
continue;
};
if history_file.exists() {
log::info!(
"Loading history from file {} for shell {}",
history_file.display(),
shell_type.name()
);
let contents = match Self::read_history_contents(
history_file.as_path(),
*shell_type,
is_kaspersky_running,
)
.await
{
Ok(contents) => contents,
Err(e) => {
log::error!("Failed to read history contents for file: {e:?}");
continue;
}
};
let history = shell_type.parse_history(&contents);
return history;
}
}
log::info!(
"No history file found for shell {}, starting with empty history",
shell_type.name()
);
Vec::new()
}
#[cfg_attr(not(windows), allow(unused_variables))]
async fn read_history_contents(
history_file: &Path,
shell_type: ShellType,
is_kaspersky_running: bool,
) -> Result<Vec<u8>, ReadHistoryContentsError> {
#[cfg(windows)]
if shell_type == ShellType::PowerShell {
return Self::read_powershell_history_contents(history_file, is_kaspersky_running)
.await;
}
async_fs::read(history_file)
.await
.map_err(ReadHistoryContentsError::AsyncFsError)
}
/// Read the PowerShell history contents by running a PowerShell command and reading the output.
///
/// This is a workaround as reading the history file using [`async_fs::read`] on Windows is a
/// trigger for certain antivirus software (Kaspersky).
#[cfg(windows)]
async fn read_powershell_history_contents(
history_file: &Path,
is_kaspersky_running: bool,
) -> Result<Vec<u8>, ReadHistoryContentsError> {
let Some(history_file_path) = history_file.as_os_str().to_str() else {
return Err(ReadHistoryContentsError::HistoryFilePathError);
};
// Try reading the history file using PowerShell commands first.
let powershell_error = match Self::read_history_via_powershell(history_file_path).await {
Ok(result) => return Ok(result),
Err(e) => e,
};
// If Kaspersky is running, early return since we can't use [`async_fs`] to read the history
// file.
if is_kaspersky_running {
return Err(ReadHistoryContentsError::PowerShellError(powershell_error));
}
// Otherwise, fall back to using [`async_fs`] to read the history file.
match async_fs::read(history_file).await {
Ok(contents) => {
// Report this error so we have some data on whether this method of running
// PowerShell commands is reliable. If this turns out to be noisy, we can remove
// this log line.
log::warn!(
"Failed to read history using PowerShell commands: {powershell_error:?}"
);
Ok(contents)
}
Err(e) => Err(ReadHistoryContentsError::PowerShellAndAsyncFsError {
powershell_error,
async_fs_error: e,
}),
}
}
#[cfg(windows)]
async fn read_history_via_powershell(history_file_path: &str) -> Result<Vec<u8>> {
let Some(powershell_command) = crate::util::windows::any_powershell_path() else {
return Err(anyhow::anyhow!(
"Failed to find powershell executable to read history"
));
};
let read_result = command::r#async::Command::new(powershell_command)
.arg("-NoProfile")
.arg("-NoLogo")
.arg("-Command")
.arg(format!(
"[System.IO.File]::ReadAllText('{history_file_path}')"
))
.output()
.await;
match read_result {
Ok(output) if output.status.success() => Ok(output.stdout),
Ok(output) => Err(anyhow::anyhow!(
"Command to read history file failed with stderr: {:#}",
String::from_utf8_lossy(&output.stderr)
)),
Err(e) => Err(anyhow::anyhow!(
"Failed to execute command to read history file: {:#}",
e
)),
}
}
async fn read_history_for_remote_session(&self) -> Vec<String> {
let histfile = &self.info.histfile;
let shell_type = self.info.shell.shell_type();
let history_files = histfile.as_ref().map_or_else(
|| shell_type.history_files(),
|histfile| vec![histfile.to_string()],
);
for history_file in history_files {
if let Some(command_history) = self.read_history_from_file(history_file.as_str()).await
{
return command_history;
}
}
log::info!(
"No history file found for shell {}, starting with empty history",
shell_type.name()
);
Vec::new()
}
async fn read_history_from_file(&self, history_file: &str) -> Option<Vec<String>> {
let env_vars = self
.info
.path
.as_deref()
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
let escaped_history_file =
shell_escape_single_quotes(history_file, self.info.shell.shell_type());
let output_in_bytes = self
.execute_command(
format!("cat '{escaped_history_file}'").as_str(),
None,
env_vars,
ExecuteCommandOptions::default(),
)
.await
.ok()?;
match output_in_bytes.status {
CommandExitStatus::Success => {
log::info!("Successfully parsed history file");
Some(
self.info
.shell
.shell_type()
.parse_history(output_in_bytes.output()),
)
}
CommandExitStatus::Failure => {
log::error!("Failed to parse history file from file");
None
}
}
}
pub async fn read_history(&self, is_kaspersky_running: bool) -> Vec<String> {
match self.info.session_type {
BootstrapSessionType::Local => {
self.read_history_for_local_session(is_kaspersky_running)
.await
}
BootstrapSessionType::WormholedRemote => self.read_history_for_remote_session().await,
}
}
pub fn environment_variable_names(&self) -> &HashSet<SmolStr> {
&self.info.environment_variable_names
}
#[cfg(feature = "integration_tests")]
pub fn external_commands(&self) -> &OnceCell<HashSet<SmolStr>> {
&self.external_commands
}
/// Returns a reference to the session's command executor for integration
/// test assertions (e.g. to verify `RemoteServerCommandExecutor` is wired).
#[cfg(any(test, feature = "integration_tests"))]
pub fn command_executor(&self) -> Arc<dyn CommandExecutor> {
self.command_executor.read().clone()
}
pub async fn execute_command(
&self,
command: &str,
current_dir_path: Option<&str>,
environment_variables: Option<HashMap<String, String>>,
execute_command_options: ExecuteCommandOptions,
) -> Result<CommandOutput> {
// Clone the Arc out of the lock so we don't hold the read guard
// across the await point.
let executor = self.command_executor.read().clone();
executor
.execute_command(
command,
&self.info.shell,
current_dir_path,
environment_variables,
execute_command_options,
)
.await
}
/// Whether the backing executor for the session supports execution of commands in parallel.
pub fn supports_parallel_command_execution(&self) -> bool {
self.command_executor
.read()
.supports_parallel_command_execution()
}
pub fn cancel_active_commands(&self) {
self.command_executor.read().cancel_active_commands();
}
pub async fn git_branches_for_command_corrections(&self, working_dir: &str) -> Vec<String> {
let env_vars = self
.info
.path
.as_deref()
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
let output = self
.execute_command(
"git --no-optional-locks branch --no-color",
Some(working_dir),
env_vars,
ExecuteCommandOptions::default(),
)
.await;
match output {
Ok(command_output) if command_output.status == CommandExitStatus::Success => {
let Ok(output_string) = command_output.to_string() else {
log::warn!(
"the output for git_branches_for_command_corrections was unparseable"
);
return vec![];
};
let res = output_string
.lines()
.map(|s| s.trim().to_string())
.collect();
res
}
_ => {
log::warn!("failed to get git_branches_for_command_corrections");
vec![]
}
}
}
pub fn command_case_sensitivity(&self) -> TopLevelCommandCaseSensitivity {
self.command_case_sensitivity
}
/// Converts the given directory into a [`typed_path::TypedPathBuf`].
pub fn convert_directory_to_typed_path_buf(&self, pwd: String) -> TypedPathBuf {
// We need to determine whether this session requires windows file paths
// or unix file paths. This needs to be resilient to wormholed ssh. Some examples:
// - bash on mac ---> unix
// - powershell on linux ---> unix
// - powershell on windows ---> windows
// - wsl on windows ---> unix
// - wormholed zsh --> unix
// If the host architecture is unix, we can infer unix file paths. This would break
// if we supported wormholing a powershell-on-windows SSH session.
if cfg!(unix) {
return TypedPathBuf::from_unix(pwd);
}
// We assume that we're on Windows.
match self.shell_family() {
// Cases: WSL, MSYS2, wormholed bash
ShellFamily::Posix => TypedPathBuf::from_unix(pwd),
// Cases: powershell sessions
ShellFamily::PowerShell => TypedPathBuf::from_windows(pwd),
}
}
}
impl Display for Session {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"shell: {}, histfile: {:?}, user: {}, host_machine: {:?}",
self.info.shell.shell_type().name(),
self.info.histfile,
self.info.user,
self.info.hostname,
)
}
}
/// Returns the hostname for the local machine where Galaxy is running.
pub fn get_local_hostname() -> Result<String> {
cfg_if::cfg_if! {
if #[cfg(not(target_family = "wasm"))] {
use gethostname::gethostname;
gethostname()
.into_string()
.map_err(|os_string| {
anyhow::anyhow!("Failed to convert local hostname OsString {os_string:?} into String.")
})
} else {
anyhow::bail!("Cannot get machine hostname from wasm")
}
}
}
#[cfg(test)]
pub mod testing {
use super::command_executor::testing::TestCommandExecutor;
use super::*;
/// Builder methods for constructing `SessionInfo` in tests.
impl SessionInfo {
pub fn new_for_test() -> Self {
let path = std::env::var_os("PATH").unwrap().into_string().ok();
#[cfg(unix)]
let shell_type = ShellType::Bash;
#[cfg(windows)]
let shell_type = ShellType::PowerShell;
Self {
session_id: SessionId::from(0),
shell: Shell::new(shell_type, None, None, Default::default(), None),
launch_data: None,
histfile: None,
user: "local:user".to_owned(),
hostname: "local:host".to_owned(),
session_type: BootstrapSessionType::Local,
subshell_info: None,
path,
editor: None,
environment_variable_names: HashSet::new(),
aliases: HashMap::new(),
abbreviations: HashMap::new(),
function_names: HashSet::new(),
builtins: HashSet::new(),
keywords: Vec::new(),
is_ssh_wrapper_session: IsSSHWrapperSession::No,
home_dir: None,
cdpath: None,
host_info: Default::default(),
wsl_name: None,
spawning_session_id: None,
}
}
pub fn with_aliases(mut self, aliases: HashMap<SmolStr, String>) -> Self {
self.aliases = aliases;
self
}
pub fn with_abbreviations(mut self, abbreviations: HashMap<SmolStr, String>) -> Self {
self.abbreviations = abbreviations;
self
}
pub fn with_builtins(mut self, builtins: HashSet<SmolStr>) -> Self {
self.builtins = builtins;
self
}
pub fn with_function_names(mut self, function_names: HashSet<SmolStr>) -> Self {
self.function_names = function_names;
self
}
pub fn with_histfile(mut self, histfile: Option<String>) -> Self {
self.histfile = histfile;
self
}
pub fn with_user(mut self, user: String) -> Self {
self.user = user;
self
}
pub fn with_session_type(mut self, session_type: BootstrapSessionType) -> Self {
self.session_type = session_type;
self
}
pub fn with_hostname(mut self, hostname: String) -> Self {
self.hostname = hostname;
self
}
pub fn with_home_dir(mut self, home_dir: String) -> Self {
self.home_dir = Some(home_dir);
self
}
pub fn with_cdpath(mut self, cdpath: String) -> Self {
self.cdpath = Some(cdpath);
self
}
pub fn with_id(mut self, id: impl Into<SessionId>) -> Self {
self.session_id = id.into();
self
}
pub fn with_ssh_socket_path(mut self, socket_path: PathBuf) -> Self {
if let BootstrapSessionType::Local = self.session_type {
self.session_type = BootstrapSessionType::WormholedRemote;
}
self.is_ssh_wrapper_session = IsSSHWrapperSession::Yes {
socket_path,
external_control_master: false,
};
self
}
pub fn with_keywords(mut self, keywords: Vec<SmolStr>) -> Self {
self.keywords = keywords;
self
}
pub fn with_path(mut self, path: Option<String>) -> Self {
self.path = path;
self
}
pub fn with_environment_variable_names(
mut self,
environment_variable_names: HashSet<SmolStr>,
) -> Self {
self.environment_variable_names = environment_variable_names;
self
}
pub fn with_shell_type(mut self, shell_type: ShellType) -> Self {
self.shell = Shell::new(
shell_type,
self.shell.version().clone(),
self.shell.options().clone(),
self.shell.plugins().clone(),
self.shell.shell_path().clone(),
);
self
}
pub fn with_shell_options(mut self, shell_options: HashSet<String>) -> Self {
self.shell = Shell::new(
self.shell.shell_type(),
self.shell.version().clone(),
Some(shell_options),
self.shell.plugins().clone(),
self.shell.shell_path().clone(),
);
self
}
}
impl Session {
pub fn test() -> Self {
let info = SessionInfo::new_for_test();
let session_type = SessionType::from(info.session_type.clone());
Self {
info,
external_commands: Default::default(),
command_executor: RwLock::new(Arc::new(TestCommandExecutor::default())),
load_external_commands_future: Default::default(),
command_case_sensitivity: TopLevelCommandCaseSensitivity::CaseSensitive,
session_type: Mutex::new(session_type),
additional_function_names: Default::default(),
load_all_function_names_future: Default::default(),
additional_builtin_names: Default::default(),
load_all_builtins_future: Default::default(),
}
}
pub fn test_remote() -> Self {
let info = SessionInfo::new_for_test()
.with_session_type(BootstrapSessionType::WormholedRemote)
.with_shell_type(ShellType::Bash); // We only support UNIX-based remote sessions.
let session_type = SessionType::from(info.session_type.clone());
Self {
info,
external_commands: Default::default(),
command_executor: RwLock::new(Arc::new(TestCommandExecutor::default())),
load_external_commands_future: Default::default(),
command_case_sensitivity: TopLevelCommandCaseSensitivity::CaseSensitive,
session_type: Mutex::new(session_type),
additional_function_names: Default::default(),
load_all_function_names_future: Default::default(),
additional_builtin_names: Default::default(),
load_all_builtins_future: Default::default(),
}
}
pub fn set_shell_options(&mut self, options: Option<HashSet<String>>) {
self.info.shell = Shell::new(
self.info.shell.shell_type(),
self.info.shell.version().clone(),
options,
self.info.shell.plugins().clone(),
self.info.shell.shell_path().clone(),
);
}
pub fn set_external_commands(&self, commands: impl IntoIterator<Item = impl AsRef<str>>) {
if self
.external_commands
.set(external_commands_with_values(commands))
.is_err()
{
log::warn!("Ignored call to set_external_commands, as external commands had already been set!");
};
}
pub fn set_environment_variables(
&mut self,
env_vars: impl IntoIterator<Item = impl Into<SmolStr>>,
) {
self.info.environment_variable_names =
HashSet::from_iter(env_vars.into_iter().map(Into::into));
}
pub fn with_shell_launch_data(mut self, launch_data: ShellLaunchData) -> Self {
self.info.launch_data = Some(launch_data);
self
}
}
fn external_commands_with_values(
values: impl IntoIterator<Item = impl AsRef<str>>,
) -> HashSet<SmolStr> {
values
.into_iter()
.map(|item| item.as_ref().into())
.collect()
}
}
#[cfg(test)]
#[path = "session_tests.rs"]
mod test;