From ab029f5ea1a1eb012eea294995e9387363bca306 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:02:22 +0800 Subject: [PATCH] fix(ssh): start a redialled SSH pane in the remote directory it was in (#1035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ssh): start a redialled SSH pane in the remote directory it was in A native SSH pane dialled again (restore after a daemon restart, Reconnect, a split, ⌘T on an SSH tab, waking a sleeping tab) always landed in the login directory. The client already sent the pane's remote cwd as `SpawnNativeSsh.cwd`, but the daemon dropped it on the floor. The daemon now threads it through `Pane::spawn_native_ssh` and `SshManager::run_session` into the shell-integration bootstrap, whose first line becomes `builtin cd -- '' 2>/dev/null` (fish-quoted for fish). It is never typed at the prompt, never lands in history, and a directory that is gone leaves the shell in the login directory without a word. Sessions without integration take the plain shell request as before, so jump-host menus never see it. The per-host probe cache still holds only the shell. Only absolute paths are honoured. No wire or protocol change. Callers now say what they mean: a saved host or quick connect passes no start dir instead of the local cwd of whatever tab was in front; ⌘T and a split on an SSH pane pass that pane's remote cwd; a sleeping SSH tab keeps its remote cwd in the session layout. The daemon's replay now sends the remote context before the cwd, so a window that reattaches to an SSH pane does not wipe the remote directory it was just told. Refs #1028 Claude-Session: https://claude.ai/code/session_011mDkkQhwx4RJJBHee3yVpq * fix(ssh): a local shell standing in for a restored SSH leaf starts locally pane_to_session now keeps a native SSH leaf's far directory, so the session_to_pane fallback that brings such a leaf back as a local shell (the redial failed, or its daemon pane is gone on reattach) would hand that remote path to a local spawn. Pass no cwd there for an SSH leaf. Claude-Session: https://claude.ai/code/session_011mDkkQhwx4RJJBHee3yVpq --- crates/tty7-core/src/daemon/pane.rs | 47 ++++- crates/tty7-core/src/daemon/protocol.rs | 9 + crates/tty7-core/src/daemon/server.rs | 10 +- .../tty7-core/src/daemon/shell_integration.rs | 195 +++++++++++++++++- crates/tty7-core/src/daemon/ssh/mod.rs | 31 ++- src/terminal/remote.rs | 18 +- src/terminal/view.rs | 16 +- src/ui/app.rs | 171 ++++++++++++--- src/ui/ssh_connect.rs | 6 +- 9 files changed, 439 insertions(+), 64 deletions(-) diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 511f3ff9..e6a96f67 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -2030,10 +2030,17 @@ impl DaemonPane { )) } + /// A pane bridged to a shell channel on `spec`'s host. + /// + /// `remote_start_dir` is a directory on that host for the shell to start + /// in. The pane's own `cwd` is deliberately left unknown until the far + /// shell reports one: the `cd` that takes it there can quietly fail, and a + /// guess recorded now would be persisted as fact. pub fn spawn_native_ssh( id: u64, size: WinSize, spec: Box, + remote_start_dir: Option, on_dead: impl FnOnce() + Send + 'static, ) -> anyhow::Result> { let allow_remote_clipboard_write = spec.remote_clipboard_write; @@ -2137,6 +2144,7 @@ impl DaemonPane { crate::daemon::ssh::SshManager::global().spawn_native_session( id, spec, + remote_start_dir, size, broker, bridge.data_tx, @@ -3123,9 +3131,6 @@ fn replay_state(st: &PaneState, subscriber: &Sender, foreground_comma let _ = subscriber.send(DaemonMsg::Snapshot(modes)); } st.ring.replay(subscriber); - if let Some(cwd) = &st.cwd { - let _ = subscriber.send(DaemonMsg::Cwd(cwd.clone())); - } if st.shell.active { let _ = subscriber.send(DaemonMsg::Prompt { active: st.shell.active, @@ -3136,6 +3141,17 @@ fn replay_state(st: &PaneState, subscriber: &Sender, foreground_comma if st.remote.is_some() { let _ = subscriber.send(DaemonMsg::RemoteContext(st.remote.clone())); } + // After the remote context, never before it. A client drops its cwd on + // every `RemoteContext`, because live that frame means the pane just hopped + // and the old directory belongs to the other side. Replayed, it is only + // the standing context, and `st.cwd` is already the far shell's own report + // (`apply_remote_context` clears it on every hop). Sent first, it would be + // wiped, and a native SSH pane reopened by a client would have no remote + // directory for ⌘T or a split to start the new pane in until its next + // prompt. + if let Some(cwd) = &st.cwd { + let _ = subscriber.send(DaemonMsg::Cwd(cwd.clone())); + } if let Some(phase) = &st.ssh_phase { let _ = subscriber.send(DaemonMsg::SshStatus { phase: phase.clone(), @@ -4947,6 +4963,31 @@ mod tests { ); } + /// The client forgets its cwd on every `RemoteContext`, so a replay that + /// sent the far shell's directory first would have it wiped straight away. + #[test] + fn a_window_reattaching_to_an_ssh_pane_keeps_its_remote_directory() { + let mut st = test_state(true); + st.remote = Some(RemoteContext { + kind: RemoteKind::NativeSsh, + argv: Vec::new(), + target: "alice@box".into(), + }); + st.cwd = Some(PathBuf::from("/home/alice/my_service")); + let (tx, rx) = std::sync::mpsc::channel(); + replay_state(&st, &tx, false); + drop(tx); + let order: Vec<&str> = rx + .iter() + .filter_map(|m| match m { + DaemonMsg::RemoteContext(_) => Some("remote"), + DaemonMsg::Cwd(_) => Some("cwd"), + _ => None, + }) + .collect(); + assert_eq!(order, ["remote", "cwd"]); + } + #[test] fn a_title_is_kept_until_it_changes_and_a_reset_clears_it() { let mut st = test_state(true); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 3056ac7a..099402a9 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -905,6 +905,15 @@ pub enum ClientMsg { }, EnsureLoopbackForward(LoopbackForwardRequest), SpawnNativeSsh { + /// The directory *on the remote host* for the shell to start in — + /// where a pane being dialled again (restore, Reconnect, a split, ⌘T) + /// last reported it was. Never a path on this machine: it is not + /// checked or canonicalized here, only handed to the far shell, and a + /// fresh connection from a saved host sends `None`. + /// + /// Honoured only when the session gets a shell-integration bootstrap + /// to carry it; a relative path is ignored, and one that no longer + /// exists leaves the shell in the login directory. cwd: Option, size: WinSize, spec: Box, diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index c369fde5..5696a6e8 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -830,8 +830,13 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { ) } - ClientMsg::SpawnNativeSsh { cwd: _, size, spec } => { + ClientMsg::SpawnNativeSsh { cwd, size, spec } => { let allow_remote_clipboard_write = spec.remote_clipboard_write; + // A far-host path that only travels as a `PathBuf`: taken as the + // text it was sent as, never resolved against this machine. One + // that is not UTF-8 could only reach the far shell mangled, so it + // is dropped and the shell starts where it would have anyway. + let remote_start_dir = cwd.and_then(|p| p.into_os_string().into_string().ok()); let id = registry.alloc_id(); let on_dead = { let registry = registry.clone(); @@ -844,7 +849,8 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { .ok(); } }; - let pane = match DaemonPane::spawn_native_ssh(id, size, spec, on_dead) { + let pane = match DaemonPane::spawn_native_ssh(id, size, spec, remote_start_dir, on_dead) + { Ok(p) => p, Err(e) => { let mut w = write_stream; diff --git a/crates/tty7-core/src/daemon/shell_integration.rs b/crates/tty7-core/src/daemon/shell_integration.rs index c2bde4af..fe7690b5 100644 --- a/crates/tty7-core/src/daemon/shell_integration.rs +++ b/crates/tty7-core/src/daemon/shell_integration.rs @@ -1309,12 +1309,56 @@ pub mod remote { RemoteShell::from_path(path).map(|shell| (shell, path.to_string())) } - pub fn bootstrap_command(shell: RemoteShell, shell_path: &str) -> String { - match shell { + /// The script a native SSH session execs in place of a bare shell request. + /// + /// `start_dir` is a directory *on the remote host* — where the pane was the + /// last time the far shell reported its cwd — and the session moves there + /// before the user's shell is exec'd, so a redialled pane comes back where + /// it was instead of in the login directory. It rides in the bootstrap + /// rather than being typed at the prompt so it never shows on screen or + /// lands in history, and a directory that has gone away since fails the + /// `cd` quietly and leaves the session in the login directory, exactly as + /// if nothing had been asked. + /// + /// The `cd` is the script's first line, ahead of the rc staging: a + /// relative `$TMPDIR` then resolves against one directory for every line + /// that follows, not one before the `cd` and another after it. + pub fn bootstrap_command( + shell: RemoteShell, + shell_path: &str, + start_dir: Option<&str>, + ) -> String { + let mut out = start_dir + .and_then(usable_start_dir) + .map(|dir| cd_line(shell, dir)) + .unwrap_or_default(); + out.push_str(&match shell { RemoteShell::Zsh => zsh_bootstrap(shell_path), RemoteShell::Bash => bash_bootstrap(shell_path), RemoteShell::Fish => fish_bootstrap(shell_path), - } + }); + out + } + + /// `dir`, if it can only mean one place on the far host. + /// + /// Absolute paths only: a relative one would resolve against the login + /// directory and through `CDPATH`, and a `~` would reach the shell quoted + /// and never expand. Neither is anything OSC 7 reports, so honouring one + /// would be a guess. A NUL cannot travel in an exec request at all. + fn usable_start_dir(dir: &str) -> Option<&str> { + (dir.starts_with('/') && !dir.contains('\0')).then_some(dir) + } + + /// `builtin`, so a `cd` wrapper defined in a file the `-c` shell already + /// read (`.zshenv`, fish's `config.fish`) cannot print, prompt or refuse; + /// `--`, so no directory name is ever taken for an option. + fn cd_line(shell: RemoteShell, dir: &str) -> String { + let quoted = match shell { + RemoteShell::Zsh | RemoteShell::Bash => shell_quote(dir), + RemoteShell::Fish => fish_quote(dir), + }; + format!("builtin cd -- {quoted} 2>/dev/null\n") } fn fish_quote(s: &str) -> String { @@ -1438,7 +1482,7 @@ fi #[test] fn zsh_bootstrap_gates_zdotdir_on_every_redirector_landing() { - let script = bootstrap_command(RemoteShell::Zsh, "/bin/zsh"); + let script = bootstrap_command(RemoteShell::Zsh, "/bin/zsh", None); for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] { assert!( script.contains(&format!("[ -s \"$__tty7_d/{name}\" ] &&")), @@ -1457,7 +1501,7 @@ fi (RemoteShell::Zsh, "/bin/zsh"), (RemoteShell::Bash, "/bin/bash"), ] { - let script = bootstrap_command(shell, path); + let script = bootstrap_command(shell, path, None); let last = script.trim_end().lines().last().unwrap(); assert_eq!( last, @@ -1469,14 +1513,14 @@ fi #[test] fn bash_bootstrap_forces_a_non_login_shell_through_the_rcfile() { - let script = bootstrap_command(RemoteShell::Bash, "/bin/bash"); + let script = bootstrap_command(RemoteShell::Bash, "/bin/bash", None); assert!(script.contains("exec '/bin/bash' --rcfile \"$__tty7_d/bashrc\" -i")); assert!(script.contains("source /etc/profile")); } #[test] fn fish_bootstrap_is_one_exec_carrying_the_escaped_body() { - let script = bootstrap_command(RemoteShell::Fish, "/usr/bin/fish"); + let script = bootstrap_command(RemoteShell::Fish, "/usr/bin/fish", None); assert!(script.starts_with("exec '/usr/bin/fish' -C '")); assert!(script.trim_end().ends_with("' -l")); assert!(!script.contains("mkdir")); @@ -1536,13 +1580,144 @@ fi (RemoteShell::Fish, "fish", "--no-execute", "/usr/bin/fish"), ]; for (shell, bin, flag, path) in cases { - let script = bootstrap_command(shell, path); - if let Some((ok, stderr)) = parse_check(bin, flag, &script) { - assert!(ok, "{bin} rejected its bootstrap script:\n{stderr}"); + for start_dir in [None, Some(AWKWARD_DIR)] { + let script = bootstrap_command(shell, path, start_dir); + if let Some((ok, stderr)) = parse_check(bin, flag, &script) { + assert!( + ok, + "{bin} rejected its bootstrap script (start dir {start_dir:?}):\n{stderr}" + ); + } } } } + /// A remote directory with everything in it that quoting has to get + /// right: a space, a single quote, a backslash and a `$`. + const AWKWARD_DIR: &str = r"/srv/my service/it's \n $HOME"; + + #[test] + fn a_start_dir_opens_every_bootstrap_with_one_quiet_cd() { + for (shell, path, expected) in [ + ( + RemoteShell::Zsh, + "/bin/zsh", + r"builtin cd -- '/srv/my service/it'\''s \n $HOME' 2>/dev/null", + ), + ( + RemoteShell::Bash, + "/bin/bash", + r"builtin cd -- '/srv/my service/it'\''s \n $HOME' 2>/dev/null", + ), + ( + RemoteShell::Fish, + "/usr/bin/fish", + r"builtin cd -- '/srv/my service/it\'s \\n $HOME' 2>/dev/null", + ), + ] { + let script = bootstrap_command(shell, path, Some(AWKWARD_DIR)); + let (first, rest) = script.split_once('\n').expect("more than one line"); + assert_eq!(first, expected, "{shell:?}"); + assert_eq!( + rest, + bootstrap_command(shell, path, None), + "{shell:?}: the cd is added in front, and nothing else changes" + ); + } + } + + #[test] + fn without_a_start_dir_the_bootstrap_has_no_cd_at_all() { + for (shell, path) in [ + (RemoteShell::Zsh, "/bin/zsh"), + (RemoteShell::Bash, "/bin/bash"), + (RemoteShell::Fish, "/usr/bin/fish"), + ] { + assert!(!bootstrap_command(shell, path, None).contains("builtin cd")); + } + } + + #[test] + fn a_start_dir_that_is_not_an_absolute_path_is_ignored() { + for dir in ["", "~/my_service", "my_service", "./x", "/a\0b"] { + let script = bootstrap_command(RemoteShell::Bash, "/bin/bash", Some(dir)); + assert_eq!( + script, + bootstrap_command(RemoteShell::Bash, "/bin/bash", None), + "{dir:?} must not be turned into a cd" + ); + } + } + + /// Runs the bootstrap's own `cd` line, then `pwd`, in `bin` the way + /// sshd runs an exec request: ` -c