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