From 36537597ca5748474d55aac8f54324f6a21dd136 Mon Sep 17 00:00:00 2001 From: Kevin Lago Date: Mon, 6 Jul 2026 16:06:09 -0400 Subject: [PATCH] fix(console): don't launch the agent when the session cwd is missing (#2438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a pane's configured cwd is gone (e.g. a fleet worktree was removed), the init line fell back to the nearest existing ancestor and STILL ran the launch command — starting the agent ungated outside its project: for a fleet worktree the fallback ancestor is the parent of EVERY worktree, and the role gate (`ensure_session_settings`) was written into the missing configured path, so nothing bounded the session. Worse, the warning that was supposed to flag this printed BEFORE the final screen clear (bash `printf '\033[2J\033[H'`, PowerShell `Clear-Host`, cmd `cls`) — or before the launched agent's TUI took over — so the user saw a normal-looking session with no trace of the problem. Now all three shell init builders (`build_bash_init_line`, and the PowerShell + cmd branches of `non_bash_init`) suppress the launch when `cwd_missing` and print the red warning AFTER the clear, as the inert shell's banner, telling the user the agent was NOT started and to relaunch the fleet (recreating the worktree) or remove the pane. The normal path is unchanged: cd, clear, then launch. Tests lock the wire contract: no launch command in the missing-cwd init line even when a launch is configured, warning positioned after the clear sequence, and the non-missing path still appending the launch — for bash, PowerShell, and cmd. Closes #2438 Co-Authored-By: Claude Fable 5 --- src-tauri/src/console/pty/launch.rs | 41 +++++++++++++++------ src-tauri/src/platform/shell/mod.rs | 55 +++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/console/pty/launch.rs b/src-tauri/src/console/pty/launch.rs index a235fc5d..5f565e1c 100644 --- a/src-tauri/src/console/pty/launch.rs +++ b/src-tauri/src/console/pty/launch.rs @@ -43,8 +43,12 @@ pub(crate) fn plan_launch( /// (a loud red warning + nearest-existing-ancestor fallback when it's `cwd_missing`, nothing when /// `cwd` is empty), install the OSC-7 cwd + `__bsc_state` run/idle markers and the `claude()` /// wrapper (`claude_fn`), source the bsc-* helpers from `rc_bash` into the interactive shell, -/// wire `PROMPT_COMMAND`, clear the screen, and append the optional `launch` command. Pure (no -/// fs/env) so the exact wire string is unit-testable; `to_bash_path` is a no-op off Windows. +/// wire `PROMPT_COMMAND`, clear the screen, and append the optional `launch` command — EXCEPT when +/// the cwd is missing (#2438): the launch is suppressed, since it would start the agent in the +/// fallback ancestor (for a fleet worktree that's the parent of EVERY worktree) with no role gate +/// (`ensure_session_settings` wrote into the missing configured path), and the agent's TUI would +/// clear the warning off screen. Pure (no fs/env) so the exact wire string is unit-testable; +/// `to_bash_path` is a no-op off Windows. pub(super) fn build_bash_init_line( cwd: &str, cwd_missing: bool, @@ -53,18 +57,25 @@ pub(super) fn build_bash_init_line( claude_fn: &str, rc_bash: &str, ) -> String { - let init_suffix = launch.map(|s| format!("; {}", s)).unwrap_or_default(); + // The suffix runs AFTER the final screen clear, so it's what the user actually sees: the launch + // command normally, or — when the configured cwd is gone — the red warning as an inert shell's + // banner (printing it any earlier gets wiped by the clear; launching anyway would start the agent + // in the fallback ancestor, ungated). + let init_suffix = if cwd_missing { + format!( + "; printf '\\033[1;31m[bsc] WARNING: configured directory %s does not exist; this session did NOT start in its project directory and the agent was NOT started. Relaunch the fleet to recreate the worktree, or remove this pane.\\033[0m\\n' \"{disp}\"", + disp = to_bash_path(cwd), + ) + } else { + launch.map(|s| format!("; {}", s)).unwrap_or_default() + }; // Explicit cd after .bashrc runs so any `cd ~` in .bashrc doesn't win. // Uses a bash-compatible POSIX path so Git Bash on Windows handles it. let cd_prefix = if cwd.is_empty() { String::new() } else if cwd_missing { - // Loud, visible warning instead of a silent home fallback, then sit in the - // nearest existing ancestor (not $HOME) so the agent is at least near the project. - format!( - "printf '\\033[1;31m[bsc] WARNING: configured directory %s does not exist; this session did NOT start in its project directory.\\033[0m\\n' \"{disp}\"; cd \"{anc}\" 2>/dev/null; ", - disp = to_bash_path(cwd), anc = to_bash_path(effective_cwd), - ) + // Sit in the nearest existing ancestor (not $HOME); the warning prints post-clear (above). + format!("cd \"{}\" 2>/dev/null; ", to_bash_path(effective_cwd)) } else { format!("cd \"{}\" 2>/dev/null; ", to_bash_path(cwd)) }; @@ -113,12 +124,22 @@ mod tests { #[test] fn build_bash_init_line_missing_cwd_warns_and_falls_to_ancestor() { + // `launch` is Some on purpose: a missing cwd must suppress it anyway (#2438) — launching + // would start the agent ungated in the fallback ancestor (for a fleet worktree, the parent + // of EVERY worktree; the role gate was written into the missing configured path). let line = build_bash_init_line( "/home/u/projects/gone", true, "/home/u/projects", - None, "claude() { :; }; ", "/rc.sh", + Some("claude --continue"), "claude() { :; }; ", "/rc.sh", ); assert!(line.contains("WARNING: configured directory"), "loud warning printed: {line}"); assert!(line.contains("cd \"/home/u/projects\" 2>/dev/null; "), "cd's into the nearest ancestor: {line}"); + assert!(line.contains("the agent was NOT started"), "warning states the launch suppression: {line}"); + assert!(!line.contains("claude --continue"), "launch suppressed when the cwd is missing: {line}"); + // The warning is the POST-clear suffix — printing it earlier gets wiped by the final + // `printf '\033[2J\033[H'` screen clear (#2438). + let clear = line.find("printf '\\033[2J\\033[H'").expect("clears the screen"); + let warn = line.find("WARNING").expect("warns"); + assert!(warn > clear, "warning must print after the screen clear: {line}"); } #[test] diff --git a/src-tauri/src/platform/shell/mod.rs b/src-tauri/src/platform/shell/mod.rs index 044f2327..3546cda4 100644 --- a/src-tauri/src/platform/shell/mod.rs +++ b/src-tauri/src/platform/shell/mod.rs @@ -261,7 +261,12 @@ pub(crate) fn resolve_interactive_shell() -> ResolvedShell { /// and startup-prompt baking are bash-only, so under PowerShell/cmd we cd into the /// project, clear the screen, and print a VISIBLE notice that those helpers are /// unavailable in this session (explicit degradation, never silent), optionally -/// launching `claude` so the session is still usable. Pure (no I/O) for testing. +/// launching `claude` so the session is still usable. When the configured cwd is +/// MISSING (#2438) the launch is suppressed — starting the agent in the fallback +/// ancestor would run it ungated outside its project (for a fleet worktree, the +/// parent of EVERY worktree; `ensure_session_settings` wrote into the missing +/// configured path) — and the red warning prints AFTER the clear (`Clear-Host` / +/// `cls`) so the clear can't wipe it. Pure (no I/O) for testing. pub(crate) fn non_bash_init( kind: ShellKind, cwd: &str, @@ -291,17 +296,17 @@ pub(crate) fn non_bash_init( // Single-quote-escape for PowerShell literal strings (' -> ''). s.push_str(&format!("Set-Location -LiteralPath '{}'; ", dir.replace('\'', "''"))); } - if cwd_missing { - s.push_str(&format!( - "Write-Host '[bsc] WARNING: configured directory {} does not exist; this session did NOT start in its project directory.' -ForegroundColor Red; ", - cwd.replace('\'', "''"), - )); - } s.push_str("Clear-Host; "); s.push_str(&format!( "Write-Host '[bsc] Running under PowerShell -- {NOTICE}.' -ForegroundColor Yellow; " )); - if launch_claude { + if cwd_missing { + // Post-clear so the warning survives; launch suppressed (#2438). + s.push_str(&format!( + "Write-Host '[bsc] WARNING: configured directory {} does not exist; this session did NOT start in its project directory and the agent was NOT started. Relaunch the fleet to recreate the worktree, or remove this pane.' -ForegroundColor Red; ", + cwd.replace('\'', "''"), + )); + } else if launch_claude { s.push_str(&format!("claude{model_arg}; ")); } s.push('\n'); @@ -312,14 +317,14 @@ pub(crate) fn non_bash_init( if !dir.is_empty() { s.push_str(&format!("cd /d \"{dir}\" & ")); } + s.push_str("cls & "); + s.push_str(&format!("echo [bsc] Running under cmd.exe -- {NOTICE}. & ")); if cwd_missing { + // Post-clear so the warning survives; launch suppressed (#2438). s.push_str(&format!( - "echo [bsc] WARNING: configured directory {cwd} does not exist; this session did NOT start in its project directory. & " + "echo [bsc] WARNING: configured directory {cwd} does not exist; this session did NOT start in its project directory and the agent was NOT started. Relaunch the fleet to recreate the worktree, or remove this pane. & " )); - } - s.push_str("cls & "); - s.push_str(&format!("echo [bsc] Running under cmd.exe -- {NOTICE}. & ")); - if launch_claude { + } else if launch_claude { s.push_str(&format!("claude{model_arg} & ")); } s.push_str("echo.\n"); @@ -465,11 +470,33 @@ mod tests { #[test] fn non_bash_init_warns_loudly_when_cwd_is_missing() { use super::{non_bash_init, ShellKind}; - let ps = non_bash_init(ShellKind::PowerShell, "C:/gone", true, "C:/", false, None); + // launch_claude = true on purpose: a missing cwd must suppress the launch anyway (#2438). + let ps = non_bash_init(ShellKind::PowerShell, "C:/gone", true, "C:/", true, None); // Lands in the existing ancestor, not the missing dir, and shouts about it. assert!(ps.contains("Set-Location -LiteralPath 'C:/'")); assert!(ps.contains("does not exist")); assert!(ps.contains("-ForegroundColor Red")); + assert!(ps.contains("the agent was NOT started"), "warning states the launch suppression: {ps}"); + // The agent is NOT started in the fallback ancestor (it would run ungated there). + assert!(!ps.contains("claude"), "no claude launch when the cwd is missing: {ps}"); + // The warning prints AFTER Clear-Host, so the clear can't wipe it off screen. + let clear = ps.find("Clear-Host").expect("clears the screen"); + let warn = ps.find("WARNING").expect("warns"); + assert!(warn > clear, "warning must print after the clear: {ps}"); + } + + #[test] + fn non_bash_init_cmd_suppresses_launch_and_warns_after_cls_when_cwd_is_missing() { + use super::{non_bash_init, ShellKind}; + // Same #2438 contract for the cmd.exe branch. + let cmd = non_bash_init(ShellKind::Cmd, r"C:\gone", true, r"C:\projects", true, Some("opus")); + assert!(cmd.contains("cd /d \"C:\\projects\""), "cd's into the nearest ancestor: {cmd}"); + assert!(cmd.contains("does not exist")); + assert!(cmd.contains("the agent was NOT started"), "warning states the launch suppression: {cmd}"); + assert!(!cmd.contains("claude"), "no claude launch when the cwd is missing: {cmd}"); + let clear = cmd.find("cls").expect("clears the screen"); + let warn = cmd.find("WARNING").expect("warns"); + assert!(warn > clear, "warning must print after the clear: {cmd}"); } #[test]