Skip to content
Merged
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 .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ jobs:
run: |
cargo test --locked -p warp --lib -- \
remote_server::headless_connect::tests \
remote_server::session_live::tests \
terminal::daemon_tty::event_loop::tests \
provisional_daemon_adoption_cleanup_covers_connection_and_tab
cargo test --locked -p remote_server --lib client::tests
Expand Down Expand Up @@ -535,6 +536,8 @@ jobs:
sessions_open_here_come_first_and_the_rest_follow_by_most_recent_attach
last_opened_time_is_reported_in_coarse_honest_steps
inventory_rows_resolve_the_exact_daemon_pty_claim_identity
detached_session_row_uses_daemon_live_cwd_and_foreground_command
old_daemon_without_live_metadata_falls_back_honestly
normalized_agent_titles_are_preserved_with_known_cwd_in_the_tooltip
stale_daemon_adoption_cannot_resolve_a_different_inventory_row
test_non_legacy_remote_session_lists_directory_entries_with_remote_server_enabled
Expand Down
1 change: 1 addition & 0 deletions app/i18n/de/warp.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ workspace-left-panel-ssh-manager-sessions-not-persistent = „Session-Persistenz
workspace-left-panel-ssh-manager-session-id = Session-ID: { $id }
workspace-left-panel-ssh-manager-session-open-here = In diesem Fenster offen
workspace-left-panel-ssh-manager-session-show-tab = Tab anzeigen
workspace-left-panel-ssh-manager-session-metadata = { $command } · { $opened }
workspace-left-panel-ssh-manager-session-last-opened-now = zuletzt geöffnet gerade eben
workspace-left-panel-ssh-manager-session-last-opened-minutes = { $count ->
[one] zuletzt geöffnet vor 1 Minute
Expand Down
1 change: 1 addition & 0 deletions app/i18n/en/warp.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -3007,6 +3007,7 @@ workspace-left-panel-ssh-manager-sessions-needs-key = Zaplex sessions need key-b
workspace-left-panel-ssh-manager-session-id = Session ID: { $id }
workspace-left-panel-ssh-manager-session-open-here = Open in this window
workspace-left-panel-ssh-manager-session-show-tab = Show tab
workspace-left-panel-ssh-manager-session-metadata = { $command } · { $opened }
workspace-left-panel-ssh-manager-session-last-opened-now = last opened just now
workspace-left-panel-ssh-manager-session-last-opened-minutes = { $count ->
[one] last opened 1 minute ago
Expand Down
23 changes: 17 additions & 6 deletions app/src/remote_server/headless_connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use warp_ssh_manager::{
use warpui::r#async::{executor::Background, FutureExt as _};
use zaplex_remote_session::types::{
has_feature, FEATURE_AGENT_INVENTORY, FEATURE_MULTIPLEXER_INVENTORY_V1, FEATURE_SESSION_HOST,
FEATURE_SESSION_LIVE_METADATA_V1,
};

use super::session_inventory::{
Expand Down Expand Up @@ -136,6 +137,17 @@ fn supports_session_host(response: &InitializeResponse) -> bool {
has_feature(&response.features, FEATURE_SESSION_HOST)
}

/// Live PTY metadata is trusted only from daemons that advertise it; from any
/// other daemon the field is dropped instead of shown.
fn discard_unsupported_live_metadata(daemon: &mut SessionList, initialize: &InitializeResponse) {
if has_feature(&initialize.features, FEATURE_SESSION_LIVE_METADATA_V1) {
return;
}
for session in &mut daemon.sessions {
session.live = None;
}
}

fn parse_release_daemon_version(version: &str) -> Option<semver::Version> {
let version = version.strip_prefix('v').unwrap_or(version);
let mut parsed = semver::Version::parse(version).ok().or_else(|| {
Expand Down Expand Up @@ -198,19 +210,17 @@ fn agent_session_display_title(session: &AgentSessionInfo) -> String {
.unwrap_or_else(|| provider.to_string())
}

/// The title carries only the foreground agent's identity. Without one it
/// stays empty, so the row derives `Host · directory` from the session's
/// directory like a pane does.
fn enrich_daemon_session_titles(daemon: &mut SessionList, agents: &AgentSessionList) {
for session in &mut daemon.sessions {
let agent = agents.sessions.iter().find(|agent| {
agent.pty_foreground
&& agent.pty_session_id == session.session_id
&& agent.pty_session_generation == session.generation
});
session.title = agent.map(agent_session_display_title).unwrap_or_else(|| {
std::path::Path::new(&session.cwd)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
});
session.title = agent.map(agent_session_display_title).unwrap_or_default();
}
}

Expand Down Expand Up @@ -731,6 +741,7 @@ async fn query_daemon_inventory(
)
.await?;
let observed_at = Instant::now();
discard_unsupported_live_metadata(&mut daemon, &initialize);
if supports_agent_inventory(&initialize) {
match inventory_before_deadline(
async {
Expand Down
33 changes: 32 additions & 1 deletion app/src/remote_server/headless_connect_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,38 @@ fn unmatched_agent_inventory_never_preserves_a_shell_title() {

enrich_daemon_session_titles(&mut daemon, &AgentSessionList::default());

assert_eq!(daemon.sessions[0].title, "project");
// The directory stays the identity source; the title is left empty.
assert!(daemon.sessions[0].title.is_empty());
assert_eq!(daemon.sessions[0].cwd, "/srv/project");
}

#[test]
fn live_metadata_is_ignored_without_the_capability() {
let listed = || SessionList {
sessions: vec![remote_server::proto::SessionInfo {
session_id: "pty-1".to_string(),
live: Some(remote_server::proto::SessionLiveMetadata {
cwd: "/srv/project".to_string(),
foreground_command: "claude".to_string(),
}),
..Default::default()
}],
..Default::default()
};

let mut old_daemon = listed();
discard_unsupported_live_metadata(&mut old_daemon, &InitializeResponse::default());
assert_eq!(old_daemon.sessions[0].live, None);

let mut capable_daemon = listed();
discard_unsupported_live_metadata(
&mut capable_daemon,
&InitializeResponse {
features: vec![FEATURE_SESSION_LIVE_METADATA_V1.to_string()],
..Default::default()
},
);
assert_eq!(capable_daemon.sessions[0].live, listed().sessions[0].live);
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions app/src/remote_server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub mod server_model;
#[cfg(unix)]
pub mod session_host;
pub mod session_inventory;
#[cfg(target_os = "linux")]
pub mod session_live;
#[cfg(not(target_family = "wasm"))]
pub mod ssh_transport;
#[cfg(not(target_family = "wasm"))]
Expand Down
8 changes: 8 additions & 0 deletions app/src/remote_server/server_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6860,6 +6860,13 @@ impl ServerModel {
(None, Some(project)) => project,
(None, None) => String::new(),
};
#[cfg(target_os = "linux")]
let live = Some(super::session_live::read_session_live_metadata(
&super::session_live::RealPtyProcfs,
session.child.id(),
));
#[cfg(not(target_os = "linux"))]
let live = None;
(
SessionInfo {
session_id: id.clone(),
Expand All @@ -6876,6 +6883,7 @@ impl ServerModel {
.as_ref()
.map(|managed| managed_session_info(managed, session.generation)),
process_memory: None,
live,
},
session
.managed
Expand Down
224 changes: 224 additions & 0 deletions app/src/remote_server/session_live.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//! Point-in-time facts about a daemon-hosted PTY that the daemon reads from
//! `/proc` when it answers `ListSessions`: the working directory and the
//! executable name of the terminal's foreground job.
//!
//! The session shell is a session leader with the PTY as its controlling
//! terminal, so field 8 (`tpgid`) of its `/proc/<pid>/stat` names the PTY's
//! foreground process group. Only executable names leave the host, never
//! command-line arguments.

use std::io;
use std::path::{Path, PathBuf};

use remote_server::proto::SessionLiveMetadata;

/// Interpreters whose first script argument names the actual command.
const INTERPRETERS: &[&str] = &[
"node", "nodejs", "bun", "deno", "python", "python3", "ruby", "perl",
];
const SCRIPT_EXTENSIONS: &[&str] = &["js", "mjs", "cjs", "ts", "py", "rb", "pl"];

pub(crate) trait PtyProcfs {
fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
fn read_link(&self, path: &Path) -> io::Result<PathBuf>;
/// Ids of all processes currently listed under `/proc`.
fn pids(&self) -> io::Result<Vec<u32>>;
}

#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct RealPtyProcfs;

impl PtyProcfs for RealPtyProcfs {
fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}

fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
std::fs::read_link(path)
}

fn pids(&self) -> io::Result<Vec<u32>> {
Ok(std::fs::read_dir("/proc")?
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse::<u32>().ok())
.collect())
}
}

/// Reads the live metadata of the PTY whose session shell is `shell_pid`.
/// Unreadable facts stay empty; nothing is guessed.
pub(crate) fn read_session_live_metadata(
procfs: &impl PtyProcfs,
shell_pid: u32,
) -> SessionLiveMetadata {
let Some(shell) = read_stat(procfs, shell_pid) else {
return SessionLiveMetadata::default();
};
// An idle prompt is the shell itself in the foreground.
let foreground = u32::try_from(shell.foreground_group)
.ok()
.filter(|group| *group != shell_pid)
.and_then(|group| foreground_process(procfs, group, shell.session));
let cwd = foreground
.and_then(|pid| read_cwd(procfs, pid))
.or_else(|| read_cwd(procfs, shell_pid))
.unwrap_or_default();
let foreground_command = foreground
.and_then(|pid| foreground_command_name(procfs, pid))
.unwrap_or_default();
SessionLiveMetadata {
cwd,
foreground_command,
}
}

/// A live process of the terminal's foreground group: its leader, or, once a
/// pipeline's leader has exited (`cat app.log | less`), the newest remaining
/// member, usually the stage the user is looking at. A group outside the
/// shell's session cannot be this terminal's job (PID reuse).
fn foreground_process(procfs: &impl PtyProcfs, group: u32, session: i32) -> Option<u32> {
let in_foreground_job = |pid: u32| {
read_stat(procfs, pid).is_some_and(|stat| {
!stat.exited && stat.session == session && u32::try_from(stat.group).ok() == Some(group)
})
};
if in_foreground_job(group) {
return Some(group);
}
procfs
.pids()
.ok()?
.into_iter()
.filter(|pid| in_foreground_job(*pid))
.max()
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct PtyProcessStat {
exited: bool,
group: i32,
session: i32,
foreground_group: i32,
}

fn read_stat(procfs: &impl PtyProcfs, pid: u32) -> Option<PtyProcessStat> {
let contents = procfs
.read(&PathBuf::from(format!("/proc/{pid}/stat")))
.ok()?;
parse_stat(&String::from_utf8_lossy(&contents))
}

/// Parses state (field 3), process group (field 5), session (field 6) and
/// terminal foreground group (field 8). The command name in field 2 may
/// contain spaces and parentheses, so fields are counted after its last
/// closing parenthesis.
fn parse_stat(contents: &str) -> Option<PtyProcessStat> {
let fields: Vec<&str> = contents[contents.rfind(')')? + 1..]
.split_whitespace()
.collect();
Some(PtyProcessStat {
exited: matches!(*fields.first()?, "Z" | "X"),
group: fields.get(2)?.parse().ok()?,
session: fields.get(3)?.parse().ok()?,
foreground_group: fields.get(5)?.parse().ok()?,
})
}

fn read_cwd(procfs: &impl PtyProcfs, pid: u32) -> Option<String> {
let cwd = procfs
.read_link(&PathBuf::from(format!("/proc/{pid}/cwd")))
.ok()?;
let cwd = cwd.to_str()?;
(!cwd.is_empty()).then(|| cwd.to_string())
}

fn foreground_command_name(procfs: &impl PtyProcfs, pid: u32) -> Option<String> {
procfs
.read(&PathBuf::from(format!("/proc/{pid}/cmdline")))
.ok()
.and_then(|cmdline| command_name_from_cmdline(&cmdline))
.or_else(|| {
let comm = procfs
.read(&PathBuf::from(format!("/proc/{pid}/comm")))
.ok()?;
let comm = String::from_utf8_lossy(&comm).trim().to_string();
(!comm.is_empty()).then_some(comm)
})
}

/// The executable's base name, or the script's for a known interpreter. Only
/// file names are reported: inline code, module names and option operands
/// are arguments, so the interpreter's own name stands in for them.
fn command_name_from_cmdline(cmdline: &[u8]) -> Option<String> {
let mut args = cmdline
.split(|byte| *byte == 0)
.filter(|arg| !arg.is_empty())
.map(String::from_utf8_lossy);
// A program that rewrites its process title puts its arguments into
// argv[0] ("npm run dev"); only the first word names the program.
let executable = args
.next()?
.split_whitespace()
.next()
.and_then(base_name)
.map(|name| name.trim_end_matches(':').to_string())
.filter(|name| is_plain_file_name(name))?;
if !INTERPRETERS.contains(&executable.as_str()) {
return Some(executable);
}
let script = args
.take_while(|arg| !runs_inline_code(arg))
.find(|arg| !arg.starts_with('-'))
.and_then(|arg| script_name(&arg));
Some(script.unwrap_or(executable))
}

/// Whether an interpreter option turns the next operand into program text
/// (`python -c`, `node -e`/`-p`/`--eval`, `perl -e`/`-E`, `ruby -e`).
fn runs_inline_code(arg: &str) -> bool {
match arg.strip_prefix("--") {
Some(long) => matches!(long.split('=').next(), Some("eval" | "print")),
None => arg.strip_prefix('-').is_some_and(|flags| {
flags
.chars()
.any(|flag| matches!(flag, 'c' | 'e' | 'E' | 'p'))
}),
}
}

/// A script's name without its extension. Only a path (`/usr/bin/claude`,
/// `./build.py`) or a file with a script extension counts as a script.
fn script_name(arg: &str) -> Option<String> {
let name = base_name(arg)?;
let is_script = arg.contains('/') || has_script_extension(&name);
(is_script && is_plain_file_name(&name)).then(|| strip_script_extension(&name))
}

fn base_name(path: &str) -> Option<String> {
let name = path.rsplit('/').next()?.trim();
(!name.is_empty()).then(|| name.to_string())
}

fn is_plain_file_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '.' | '_' | '-' | '+' | '@'))
}

fn has_script_extension(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(stem, extension)| !stem.is_empty() && SCRIPT_EXTENSIONS.contains(&extension))
}

fn strip_script_extension(name: &str) -> String {
match name.rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() && SCRIPT_EXTENSIONS.contains(&extension) => {
stem.to_string()
}
_ => name.to_string(),
}
}

#[cfg(test)]
#[path = "session_live_tests.rs"]
mod tests;
Loading
Loading