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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 65 additions & 1 deletion app/src/terminal/writeable_pty/command_history.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,69 @@
use std::collections::HashSet;
use std::sync::mpsc::SyncSender;
use std::sync::Arc;

use lazy_static::lazy_static;
use parking_lot::FairMutex;
use warp_terminal::shell::{read_shell_history, write_command_to_shell_history, ShellType};
use warpui::{AppContext, ModelHandle, SingletonEntity};

use crate::persistence::StartedCommandMetadata;
use crate::terminal::model::session::SessionId;
use crate::terminal::{view::ExecuteCommandEvent, TerminalModel};
use crate::terminal::{History, HistoryEntry};
use crate::{persistence::ModelEvent, terminal::model::session::Sessions};

lazy_static! {
static ref SHELL_HISTORY_SYNC_INITIALIZED: std::sync::Mutex<HashSet<String>> =
std::sync::Mutex::new(HashSet::new());
}

pub fn ensure_shell_history_synced(session_id: SessionId, shell_type: ShellType, ctx: &mut AppContext) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] ensure_shell_history_synced is never called in this PR, so shell-history → Warp sync never runs; invoke it during session initialization before claiming bidirectional sync.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] ensure_shell_history_synced is never called in this PR, so shell-history → Warp sync never runs; invoke it during session initialization before claiming bidirectional sync.

let key = format!("{:?}-{:?}", session_id, shell_type);

let mut initialized = SHELL_HISTORY_SYNC_INITIALIZED.lock().unwrap();
if initialized.contains(&key) {
return;
}
initialized.insert(key.clone());
drop(initialized);

let existing_commands: HashSet<String> = {
let history = History::handle(ctx);
history
.commands_shared(session_id)
.map(|commands| {
commands
.iter()
.map(|c| c.command.clone())
.collect::<HashSet<_>>()
})
.unwrap_or_default()
};

match read_shell_history(shell_type, &existing_commands) {
Ok(shell_commands) => {
if !shell_commands.is_empty() {
log::info!(
"Syncing {} commands from shell history for session {:?}",
shell_commands.len(),
session_id
);
History::handle(ctx).update(ctx, move |history, _| {
let entries: Vec<HistoryEntry> = shell_commands
.into_iter()
.map(HistoryEntry::command_only)
.collect();
history.append_commands(session_id, entries);
});
}
}
Err(e) => {
log::debug!("Could not read shell history: {}", e);
}
}
}

pub fn update_command_history(
event: &ExecuteCommandEvent,
model: &Arc<FairMutex<TerminalModel>>,
Expand Down Expand Up @@ -66,11 +121,20 @@ pub fn update_command_history(
};
ctx.background_executor()
.spawn(async move {
// Sending over a sync sender can block the current thread, so we do this async.
if let Err(e) = sender_clone.send(insert_command_event) {
log::error!("Error sending ModelEvent: {e:?}");
}
})
.detach();
}

let shell_type = session.shell().shell_type();
let command = event.command.to_string();
ctx.background_executor()
.spawn(async move {
if let Err(e) = write_command_to_shell_history(shell_type, &command) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] [SECURITY] This writes every session's command into the local user's shell history based only on shell type; remote, WSL, and container commands can be copied into host history and expose sensitive remote commands. Gate this to local/native sessions or resolve the history file in the session environment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] [SECURITY] This writes every session's command into the local user's shell history based only on shell type; remote, WSL, and container commands can be copied into host history and expose sensitive remote commands. Gate this to local/native sessions or resolve the history file in the session environment.

log::debug!("Failed to write command to shell history: {}", e);
}
})
.detach();
}
3 changes: 3 additions & 0 deletions crates/warp_terminal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ bitflags.workspace = true
bitflags-serde-legacy.workspace = true
cfg-if.workspace = true
channel_versions.workspace = true
chrono.workspace = true
command-corrections.workspace = true
dirs.workspace = true
enum-iterator.workspace = true
get-size.workspace = true
itertools.workspace = true
Expand All @@ -37,6 +39,7 @@ warpui.workspace = true

[dev-dependencies]
unicode-segmentation = "1.11.0"
tempfile = "3.8"

[features]
test-util = []
136 changes: 136 additions & 0 deletions crates/warp_terminal/src/shell/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
mod unescape;

use std::collections::{HashMap, HashSet};
use std::fs::{self, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::ops::Deref;
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -963,6 +965,140 @@ fn zsh_unmetafy(content: &[u8]) -> String {
}
}

/// Error type for shell history sync operations.
#[derive(Debug, thiserror::Error)]
pub enum ShellHistorySyncError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Failed to expand path: {0}")]
PathExpansion(String),
}

/// Expands a tilde path to the user's home directory.
fn expand_tilde_path(path: &str) -> Result<PathBuf, ShellHistorySyncError> {
if path.starts_with("~/") {
let home = dirs::home_dir().ok_or_else(|| {
ShellHistorySyncError::PathExpansion("Cannot determine home directory".to_string())
})?;
Ok(home.join(path.trim_start_matches("~/")))
} else {
Ok(PathBuf::from(path))
}
}

/// Reads shell history from the standard history file and returns unique commands.
///
/// This function reads from the shell's history file (e.g., `~/.bash_history`,
/// `~/.zsh_history`) and returns a deduplicated list of commands. Commands already
/// present in `existing_commands` are excluded to avoid duplicates when merging
/// with Warp's own history.
pub fn read_shell_history(
shell_type: ShellType,
existing_commands: &HashSet<String>,
) -> Result<Vec<String>, ShellHistorySyncError> {
let history_files = shell_type.history_files();
let mut all_commands: Vec<String> = Vec::new();

for history_file_path in history_files {
let path = match expand_tilde_path(&history_file_path) {
Ok(p) => p,
Err(e) => {
log::debug!("Skipping history file '{}': {}", history_file_path, e);
continue;
}
};

if !path.exists() {
log::debug!("History file does not exist: {:?}", path);
continue;
}

let file = match fs::File::open(&path) {
Ok(f) => f,
Err(e) => {
log::warn!("Failed to open history file {:?}: {}", path, e);
continue;
}
};

let reader = BufReader::new(file);
let mut seen: HashSet<String> = HashSet::new();

for line in reader.lines() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This reads raw history lines instead of using ShellType::parse_history, so zsh extended-history, fish YAML, and bash timestamp lines are imported as bogus commands; read the file bytes and parse with the existing per-shell parser before filtering.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This reads raw history lines instead of using ShellType::parse_history, so zsh extended-history, fish YAML, and bash timestamp lines are imported as bogus commands; read the file bytes and parse with the existing per-shell parser before filtering.

let line = match line {
Ok(l) => l,
Err(e) => {
log::debug!("Failed to read line from {:?}: {}", path, e);
continue;
}
};

let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}

if seen.contains(&trimmed) {
continue;
}
seen.insert(trimmed.clone());

if existing_commands.contains(&trimmed) {
continue;
}

all_commands.push(trimmed);
}
}

Ok(all_commands)
}

/// Writes a command to the shell's history file.
///
/// This allows Warp's command history to be shared with other terminal sessions.
/// The command is appended to the history file in the format expected by the shell.
pub fn write_command_to_shell_history(
shell_type: ShellType,
command: &str,
) -> Result<(), ShellHistorySyncError> {
let history_files = shell_type.history_files();
let first_history_file = history_files
.into_iter()
.next()
.ok_or_else(|| ShellHistorySyncError::PathExpansion("No history file configured".to_string()))?;

let path = expand_tilde_path(&first_history_file)?;
write_command_to_shell_history_to_path(shell_type, command, &path)
}

/// Writes a command to a specific history file path.
/// This is useful for testing with temporary files.
pub fn write_command_to_shell_history_to_path(
shell_type: ShellType,
command: &str,
path: &Path,
) -> Result<(), ShellHistorySyncError> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)?;

let line = match shell_type {
ShellType::Zsh => {
format!(": {}:0;{}\n", chrono::Utc::now().timestamp(), command)
}
ShellType::Bash | ShellType::Fish | ShellType::PowerShell => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Fish history is not line-oriented command text; writing command\n makes entries invisible to fish. Emit fish's - cmd: ... format with the same escaping that parse_history expects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Fish history is not line-oriented command text; writing command\n makes entries invisible to fish. Emit fish's - cmd: ... format with the same escaping that parse_history expects.

format!("{}\n", command)
}
};

file.write_all(line.as_bytes())?;
file.flush()?;

Ok(())
}

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
109 changes: 109 additions & 0 deletions crates/warp_terminal/src/shell/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,112 @@ fn test_should_add_command_to_history() {
assert!(fish_shell.should_add_command_to_history(" asdf"));
}
}

#[test]
fn test_expand_tilde_path() {
use std::path::PathBuf;

let home = dirs::home_dir().unwrap();
let expected_bash = home.join(".bash_history");
let result = expand_tilde_path("~/.bash_history").unwrap();
assert_eq!(result, expected_bash);

let result_non_tilde = expand_tilde_path("/etc/passwd").unwrap();
assert_eq!(result_non_tilde, PathBuf::from("/etc/passwd"));
}

#[test]
fn test_read_shell_history_empty() {
use std::collections::HashSet;

let existing: HashSet<String> = HashSet::new();
let result = read_shell_history(ShellType::Bash, &existing);
assert!(result.is_ok());
}

#[test]
fn test_read_shell_history_filters_duplicates() {
use std::collections::HashSet;

let existing: HashSet<String> = vec!["ls".to_string(), "pwd".to_string()]
.into_iter()
.collect();
let result = read_shell_history(ShellType::Bash, &existing);
assert!(result.is_ok());
}

#[cfg(unix)]
mod unix_tests {
use super::*;
use std::fs;
use tempfile::TempDir;

#[test]
fn test_read_shell_history_nonexistent_file() {
use std::collections::HashSet;

let existing: HashSet<String> = HashSet::new();
let result = read_shell_history(ShellType::Bash, &existing);
assert!(result.is_ok());
}

#[test]
fn test_read_shell_history_filters_existing_commands() {
use std::collections::HashSet;

let existing: HashSet<String> = vec!["ls".to_string(), "cd /tmp".to_string()]
.into_iter()
.collect();
let result = read_shell_history(ShellType::Bash, &existing);
assert!(result.is_ok());
}

#[test]
fn test_write_command_to_shell_history_to_path_bash() {
let temp = TempDir::new().unwrap();
let histfile_path = temp.path().join(".bash_history");

let result = write_command_to_shell_history_to_path(
ShellType::Bash,
"ls -la",
histfile_path.as_path(),
);
assert!(result.is_ok());

let contents = fs::read_to_string(&histfile_path).unwrap();
assert!(contents.contains("ls -la\n"));
}

#[test]
fn test_write_command_to_shell_history_to_path_zsh() {
let temp = TempDir::new().unwrap();
let histfile_path = temp.path().join(".zsh_history");

let result = write_command_to_shell_history_to_path(
ShellType::Zsh,
"echo hello",
histfile_path.as_path(),
);
assert!(result.is_ok());

let contents = fs::read_to_string(&histfile_path).unwrap();
assert!(contents.starts_with(": "));
assert!(contents.contains(";echo hello\n"));
}

#[test]
fn test_write_command_to_shell_history_to_path_fish() {
let temp = TempDir::new().unwrap();
let histfile_path = temp.path().join("fish_history");

let result = write_command_to_shell_history_to_path(
ShellType::Fish,
"curl example.com",
histfile_path.as_path(),
);
assert!(result.is_ok());

let contents = fs::read_to_string(&histfile_path).unwrap();
assert!(contents.contains("curl example.com\n"));
}
}