fix(cwd): never run local filesystem operations on a remote pane's cwd

A remote pane's OSC 7 reports a path in the remote's namespace. The
git-status probe, path completion, link resolution, the git-diff and
worktree shell-outs, session persistence and cwd inheritance all took it
as a local path.

Nothing gated this. `refresh_git_status`'s doc claimed remote panes have
no cwd, but that holds only *before* the remote shell's OSC 7 arrives —
after it, a native-SSH pane's remote path was fed to a local `git`, with
only `Path::exists()` incidentally saving it. That guard is weakest
exactly where it matters: on Windows `/home/me/proj` is not an invalid
path but a drive-relative one resolving to `C:\home\me\proj`, so a
machine that has such a directory would report an unrelated repo's branch
and diff as the pane's own. Correctness must not rest on that collision
never happening.

Add `TerminalView::local_cwd()` — the pane's cwd only when the pane is
not remote — and route every local filesystem/Command consumer through
it. Cwd inheritance for new tabs and splits is gated too: an inherited
cwd wins over every fallback in `initial_working_directory`, so a remote
path reached the spawn as a Win32 working directory.

Deliberately unchanged: `tab_cwd`, so "Copy Working Directory" still
copies a remote pane's remote path, which is what it is for.

Not fixed here: on a remote pane, path completion falls back to tty7's
own directory and link resolution stops matching relative paths. Both
degrade rather than mislead now; sourcing either from the remote is a
separate piece of work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
thomas
2026-07-19 18:28:01 +08:00
co-authored by Claude Opus 4.8
parent 2e4cc77a47
commit 4e97253b53
3 changed files with 87 additions and 18 deletions
+3 -2
View File
@@ -8,8 +8,9 @@
.brooks-lint-history.json
openwiki/.last-update.json
# dev-build throwaway config dir (cargo dev)
.tty7-dev/
# dev-build throwaway config dirs (`cargo dev`, plus per-branch ones passed as
# --config-dir so a test run can't disturb a real profile)
.tty7-dev*/
# Claude Code agent worktrees (transient)
.claude/worktrees/
+41 -7
View File
@@ -1065,6 +1065,26 @@ impl TerminalView {
self.terminal.remote_context()
}
/// The pane's cwd *only when it names a directory on this machine* — the
/// accessor every local filesystem or `Command` use must go through.
///
/// A remote pane's OSC 7 reports a path in the remote's namespace
/// (`/home/me/proj` from an SSH host). Feeding that to a local `git` or
/// `read_dir` is meaningless, and on Windows it is worse than meaningless:
/// `/home/me/proj` is not an absolute path there but a *drive-relative*
/// one, so it silently resolves to `C:\home\me\proj`. That usually just
/// fails an `exists()` check — but if such a directory happens to exist,
/// the pane reports an unrelated local repo's branch and diff as its own.
/// Correctness must not rest on that collision never happening.
///
/// Note this gates on the pane being remote, not on the shape of the path:
/// a local shell may legitimately sit in a directory whose name looks
/// remote, and Git Bash reports genuinely local paths (via `pwd -W`) that
/// merely originate from a POSIX-looking shell.
pub fn local_cwd(&self) -> Option<std::path::PathBuf> {
self.remote_context().is_none().then(|| self.cwd())?
}
/// The coding agent running in this pane's foreground, or `None` when none
/// is. Identity comes from the daemon's foreground-`argv` detection (plus
/// the sentinel event channel, which can brand wrappers argv can't see
@@ -2584,7 +2604,7 @@ impl TerminalView {
.terminal
.agent_session()
.and_then(|s| s.cwd)
.or_else(|| self.cwd());
.or_else(|| self.local_cwd());
if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
self.refresh_git_status(cwd_now, cx);
}
@@ -2595,8 +2615,11 @@ impl TerminalView {
/// brackets the flight (`begin_probe`/`finish_probe`): a probe already in
/// flight for the same cwd absorbs this trigger instead of spawning a
/// duplicate `git` shell-out, and reruns once when it lands. With no cwd
/// (e.g. a native-SSH pane pre-OSC-7, where a local `git` would be
/// meaningless) the pane simply stops reading a status.
/// (e.g. a remote pane, where a local `git` would be meaningless) the pane
/// simply stops reading a status. Callers must source the cwd from
/// [`local_cwd`](Self::local_cwd): a remote pane *does* get a cwd once its
/// OSC 7 lands, so "remote panes have no cwd" holds only before that and
/// cannot be what keeps the local probe away from a remote path.
///
/// [`GitStatusCache`]: crate::terminal::git_status::GitStatusCache
fn refresh_git_status(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
@@ -3320,8 +3343,13 @@ impl TerminalView {
return;
}
// Fresh completion.
let Some(cwd) = self.cwd().or_else(|| std::env::current_dir().ok()) else {
// Fresh completion. Path candidates come off the local filesystem, so
// the cwd must be a local one; on a remote pane this falls back to
// tty7's own dir, which is what already happened there before OSC 7
// landed. Command/history completion is unaffected either way. Real
// remote-aware path completion would need the listing to come from the
// remote and is out of scope here.
let Some(cwd) = self.local_cwd().or_else(|| std::env::current_dir().ok()) else {
return;
};
let line = self.cmd.text();
@@ -3868,7 +3896,11 @@ impl TerminalView {
text.push(term.grid()[line][Column(c)].c);
}
drop(term);
let cwd = self.cwd();
// A relative path in the output is resolved against the cwd and
// stat-checked, then handed to the local file opener — so a remote
// pane's cwd must not be used. There, only absolute-looking local hits
// and URLs remain clickable.
let cwd = self.local_cwd();
if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) {
match link.target {
LinkTarget::Url(url) => self.open_url(&url, cx),
@@ -4020,7 +4052,9 @@ impl TerminalView {
text.push(term.grid()[line][Column(c)].c);
}
drop(term);
let cwd = self.cwd();
// Same gate as the click path above — hover must not underline a link
// the click cannot open.
let cwd = self.local_cwd();
let link =
super::search::link_at(&text, col, cwd.as_deref(), include_files).or_else(|| {
include_loopback.then(|| {
+43 -9
View File
@@ -2073,11 +2073,14 @@ impl Tty7App {
cx: &mut Context<Self>,
) {
// Inherit the cwd of the active tab's focused terminal so the new tab
// opens in the same directory the user is currently working in.
// opens in the same directory the user is currently working in. Local
// cwds only: the new tab is a local shell, and an inherited cwd wins
// over every fallback in `pane::initial_working_directory`, so a remote
// path would be handed straight to the spawn as a working directory.
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).cwd())
.and_then(|leaf| leaf.read(cx).local_cwd())
});
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
// Leaving the current tab for the new one; snapshot its focused pane
@@ -2172,8 +2175,10 @@ impl Tty7App {
};
// The new pane inherits the cwd — and the shell, when the pane being
// split was opened with an explicit pick (a WSL/fish tab splits into
// more WSL/fish, not back to the default).
let cwd = target.read(cx).cwd();
// more WSL/fish, not back to the default). Local cwds only: the
// native-SSH branch below has the daemon discard it regardless, and the
// local branch would otherwise spawn against a remote path.
let cwd = target.read(cx).local_cwd();
// Splitting a native-SSH pane opens another SSH pane on the same
// connection rather than dropping back to a local shell. Re-resolve the
// persisted (secret-free) spec from its saved profile so keychain
@@ -2470,7 +2475,7 @@ impl Tty7App {
// Capture the tab's cwd *before* its panes are killed (the daemon can't
// report it afterwards): if it sat in a tty7-managed worktree, the
// cleanup offer below needs it.
let worktree_cwd = self.tab_cwd(index, window, cx);
let worktree_cwd = self.tab_local_cwd(index, window, cx);
// Snapshot the tab (layout + each pane's current cwd + name) onto the
// recently-closed stack so Cmd+Shift+T can bring it back.
let snapshot = tab_to_session(&self.tabs[index], cx);
@@ -2517,11 +2522,14 @@ impl Tty7App {
let Some(cwd) = cwd else { return };
// Every leaf of every surviving tab, not just focused panes — a shell
// tucked away in a split occupies the worktree all the same.
// Local paths only: this list is what stops a worktree being removed
// out from under a live shell, and a remote cwd can neither occupy a
// local worktree nor be compared against one meaningfully.
let open_cwds: Vec<std::path::PathBuf> = self
.tabs
.iter()
.flat_map(|tab| tab.pane.leaves())
.filter_map(|leaf| leaf.read(cx).cwd())
.filter_map(|leaf| leaf.read(cx).local_cwd())
.collect();
cx.spawn(async move |this, cx| {
let Some(wt) = cx
@@ -2655,6 +2663,23 @@ impl Tty7App {
.and_then(|leaf| leaf.read(cx).cwd())
}
/// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine —
/// for the worktree operations, which shell out to a local `git`. "Copy
/// Working Directory" deliberately keeps using `tab_cwd`: copying a remote
/// pane's remote path is exactly what the user wants there.
fn tab_local_cwd(
&self,
index: usize,
window: &Window,
cx: &App,
) -> Option<std::path::PathBuf> {
self.tabs
.get(index)?
.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).local_cwd())
}
/// "New Worktree Tab": probe the repository containing the tab's cwd for
/// defaults (a fresh generated name, the current branch as start point) on
/// the background executor, then open the confirmation sheet
@@ -2666,7 +2691,7 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(cwd) = self.tab_cwd(index, window, cx) else {
let Some(cwd) = self.tab_local_cwd(index, window, cx) else {
window.push_notification("This tab has no working directory yet", cx);
return;
};
@@ -3039,7 +3064,10 @@ impl Tty7App {
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.and_then(|view| view.read(cx).cwd());
// Local `git` shell-out, so a remote pane's cwd is not usable —
// it reports "no known directory" rather than silently diffing
// whatever the path collides with locally.
.and_then(|view| view.read(cx).local_cwd());
let Some(cwd) = cwd else {
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
return;
@@ -4519,7 +4547,13 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
Pane::Leaf(view) => {
let view = view.read(cx);
SessionPane::Leaf {
cwd: view.cwd(),
// Local cwd only. A restored pane whose daemon pane is gone
// respawns on the *default local shell* (a shell pick isn't
// persisted), so a remote cwd would come back paired with a
// local shell that cannot chdir into it. Native-SSH panes
// reconnect from `ssh_spec` and the daemon discards the cwd
// for them anyway (`server::SpawnNativeSsh`).
cwd: view.local_cwd(),
pane_id: Some(view.pane_id),
// Persist the secret-free native-SSH spec so a *dead* pane can be
// reconnected on restore (FR-E4/C2); `None` for local panes. A